Skip to content

Promotions (Storefront)

Flow ID: CF-12 | Module(s): eshop, Product (domain) | Complexity: Medium

Business Overview

Promotional landing pages allow marketing teams to create curated product collections accessible via friendly URLs. Each promo page is a standalone marketing landing with its own visual identity, SEO metadata, and product selection. Promos serve multiple business purposes: seasonal campaigns, brand-specific showcases, clearance events, and featured product collections.

What customers see:

  • A promo landing page at /promo/{slug} with a product grid
  • Responsive banner images (separate desktop and mobile banners)
  • Rich HTML description content above and below the product grid
  • Optional block builder layout for complex visual arrangements
  • Custom header and text color theming per promo
  • Product cards with full pricing, discount, stock, and variation data
  • Category sidebar navigation (unless overridden by client)

What the business configures (via admin):

  • Promo master record with visual settings (view type, colors, images)
  • Per-language translations: name, slug, description, footer description, SEO meta tags
  • Per-language builder block associations for rich layouts
  • Curated product list with manual ordering (drag-and-drop)
  • SEO dofollow toggle to control search engine indexing
  • Products can be batch-added or batch-removed from promos via the products admin

Additional promo touchpoints:

  • Cart page: a configurable promo provides randomized product recommendations (via cart_promo_id config)
  • Home page: promo vendors are loaded as part of the homepage rendering

API Reference

Legacy Storefront Routes

Defined in application/config/routes.php:

URL PatternControllerMethodDescription
/promoAdv_promoindex()Promo index (no slug)
/promo/{slug}Adv_promoindex($slug)Render promo page by slug
/{lang}/promo/{slug}Adv_promoindex($slug)Localized promo page

REST API Endpoints (Admin/Backend)

Defined in application/config/rest_routes.php. Authentication: JWT backend, roles: AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING, AUTH_ROLE_MEDIA.

MethodEndpointController MethodDescription
GET/rest/product/promoindex()List promos (paginated, filterable)
GET/rest/product/promo/itemitem()Get single promo by filters
GET/rest/product/promo/{id}show($id)Get promo by ID
POST/rest/product/promostore()Create new promo
POST/rest/product/promo/{id}update($id)Update existing promo
DELETE/rest/product/promo/{id}destroy($id)Delete promo

All endpoints support locale prefix: /{lang}/rest/product/promo/...

Filters: id (exact), viewType (exact), name.{locale} (partial), slug.{locale} (exact)

Sorts: id, viewType, name.{locale}

Relations: translations (one-to-many), products (many-to-many)

File uploads: promo_image and promo_small_banner via multipart/form-data


Code Flow

Step 1: Route Resolution

The request to /promo/{slug} is matched by application/config/routes.php:

$route['promo/(.+)'] = 'eshop/promo/index/$1';
$route['(\w{2})/promo/(.+)'] = 'eshop/promo/index/$2';

This routes to Adv_promo::index($slug) in the HMVC eshop module. The application-level controller at application/modules/eshop/controllers/Promo.php extends Adv_promo (which extends Front_c).

Step 2: Controller Construction

ecommercen/eshop/controllers/Adv_promo.php constructor:

  1. Loads models: product_model, vats_model, promo_model
  2. Loads pagination helper
  3. Instantiates BlockBuilder for optional rich layouts
  4. Sets $this->render['is_promo'] = true as a view flag

Step 3: Promo Lookup (Cached)

php
$promo = $this->pscache->model(
    'promo_model', 'getMuiRecord',
    [['slug' => $slug, 'lang' => $this->language_abbr]],
    $this->pscache_ttl
);

The PSCache library wraps model calls with a PSR-6 cache layer (FileAdapter or RedisAdapter). Cache TTL comes from config('cache_l2_default_expires').

promo_model::getMuiRecord() joins shop_promo with shop_promo_mui and returns a single row with fields: id, promo_image, promo_small_banner, view_type, header_color, text_color, do_follow, promo_name, promo_slug, meta_title, meta_description, meta_keywords, description, footer_description, builder_block_id.

If the promo is not found, $this->error_404() is called and the method returns.

Step 4: Dofollow Resolution

php
$dofollow = !((isset($promo->do_follow) && $promo->do_follow === "0"));

The dofollow flag is passed to the main layout view. In application/views/main.php (and default.php), when $dofollow === false, the template emits <meta name="robots" content="noindex,nofollow"/>. This gives per-promo control over search engine crawling. Additionally, Adv_front_controller::setNoFollow() sets dofollow to false when query string parameters are present.

Step 5: Product Loading (Cached)

php
$this->render['records'] = $this->pscache->model(
    'product_model', 'getPromoProducts',
    [['shop_promo_products.promo_id' => $promo->id,
      'shop_product_mui.lang' => $this->language_abbr,
      'shop_product.active' => true,
      'shop_product.price >' => 0]],
    $this->pscache_ttl
);

product_model::getPromoProducts() (ecommercen/eshop/models/Adv_product_model.php) executes:

  • Selects product fields: id, slug, name, price, pieces, soft_delete, description, video, vendor info, category slug, negative_stock
  • Joins: shop_product -> shop_product_mui -> shop_product_category_lp -> shop_product_category_mui -> shop_vendor -> shop_vendor_mui -> shop_promo_products
  • Filters by language for both product and vendor MUI tables
  • Orders by shop_promo_products.order ASC (manual ordering)
  • Groups by product_id to prevent duplicates from multiple category associations
  • Supports optional $limit and $random parameters (used by cart promo, not storefront)

Product IDs are collected into $this->productIdsToParse (a UniqueCollection) for downstream enrichment.

Step 6: Block Builder Resolution

php
$promoViewFullPage = promoViewFullPage();
$this->render = $this->blockBuilder->frontRender($this->render, $promo->builder_block_id, 'promoPage');

The BlockBuilder library (application/modules/builder/libraries/BlockBuilder.php) checks if the promo's MUI record has a builder_block_id. If so, it loads the block data and stores it in $this->render['blockData']['promoPage'].

View selection logic (three paths):

  1. Builder block exists (blockData['promoPage'] is set): Uses the promoBuilder layout view, which renders the builder component. Template key promoBuilder maps to layouts/builder/promo.php.

  2. Full-page promo view (promoViewFullPage[$view_type] is true): Loads the view directly without the site header/footer wrapper. View file determined by promoView($view_type).

  3. Standard promo view (default): Loads the promo view as view_content within the site's main template. View file: {client_views}/promo/{promoView($view_type)}.

Step 7: Sidebar and Default Rendering

php
$this->promoExtra();
$this->defaultRender();

promoExtra() loads the category tree for sidebar navigation:

php
$this->render['categories_search'] = $this->pscache->model(
    'product_category_model', 'getRecordsTree',
    [['parent_id' => 0, 'published' => true]],
    $this->pscache_ttl
);

defaultRender() (from Adv_front_controller) adds standard storefront data: client views path, canonical URL, customer favorites, category slugs, product badges, menu, footer, push notifications, cart data, attributes, variations, and JSON state.

Step 8: Product Enrichment

php
$this->defaultProductParseWithProductCodes($this->productIdsToParse->getItems());

This calls four methods sequentially:

  1. defaultProductParse() -- loads pricing data (discounts, final prices, VAT, currency) from the prices view and attaches it to $this->render['liveData']
  2. defaultGiftsFromProductParse() -- loads gift/promotion rules for products
  3. defaultProductCodesParse() -- loads product codes (SKU, EAN, etc.) into $this->render['productCodes']
  4. defaultProductCodeImagesParse() -- loads product code images into $this->render['productCodeImages']

Step 9: View Rendering

The view (e.g., application/views/main/promo/promo.php) iterates over products and for each:

  1. Calls mergeDataToProduct() to combine the product record with live pricing data, product codes, and code images
  2. Calls transformProductForJson() to add the product to the page's structured data
  3. Optionally calls trackProductPromotionsHelper() for analytics tracking data
  4. Renders the productCard component view for each product

The view displays:

  • Breadcrumbs (if available)
  • Responsive banner image (desktop: promo_image, mobile: promo_small_banner)
  • Promo name as <h1>
  • Description HTML content
  • Product grid (responsive columns)
  • Footer description HTML content

Domain Layer

Modern Domain: Advisable\Domains\Product\Promo

Located at src/Domains/Product/Promo/. Provides the read/write service layer used by the REST API.

Entity (Repository/Entity.php):

  • Properties: id, promo_image, promo_small_banner, view_type, header_color, text_color, do_follow
  • Implements FilterTranslation via FiltersTranslation trait
  • Table: shop_promo

MUI Entity (Repository/MuiEntity.php):

  • Properties: id, promo_id, slug, meta_title, meta_keywords, meta_description, name, description, footer_description, lang, builder_block_id
  • Table: shop_promo_mui

Repository (Repository/Repository.php):

  • Extends BaseRepository, table shop_promo
  • Uses Specification pattern (Filter, Sort, Pagination, WithRelations)

Repository Configurator (Repository/RepositoryConfigurator.php):

  • translations: ONE_TO_MANY relation via MuiRepository on promo_id
  • products: MANY_TO_MANY relation via shop_promo_products junction table, linking to Product\Repository\Repository with promo_id / product_id

MUI Repository (Repository/MuiRepository.php):

  • Extends BaseRepository, table shop_promo_mui
  • Uses NullRelationConfigurator (no nested relations)

Service (Service.php):

  • Implements ReadService and RendersPagination
  • Methods: all() (paginated list), item() (single by filter), get() (by ID with relations)
  • Builds specifications from ListRequest filters and sorts

ListRequest (ListRequest.php):

  • Allowed filters: id (exact), viewType (exact), name.{locale} (partial), slug.{locale} (exact)
  • Allowed sorts: id, viewType, name.{locale}
  • Relation sorts: translations.name

WriteService (WriteService.php):

  • Methods: create(), update(), delete()
  • Transactional: wraps insert/update/delete in DB transactions
  • Handles MUI translations: parses translations array, validates each, inserts/replaces for entity
  • Uses WriteData and MuiWriteData value objects
  • Validation via Validator class (checks lang required on translations)

WriteData (WriteData.php):

  • Fields: promoImage, promoSmallBanner, viewType, headerColor, textColor, doFollow
  • Accepts both snake_case and camelCase input keys

MuiWriteData (MuiWriteData.php):

  • Fields: lang, slug, metaTitle, metaKeywords, metaDescription, name, description, footerDescription, builderBlockId

Architecture

Layer Interaction

Browser Request
    |
    v
[CI Router] ---> Adv_promo::index($slug)
    |                |
    |                +--> PSCache -> promo_model::getMuiRecord()     [shop_promo + shop_promo_mui]
    |                +--> PSCache -> product_model::getPromoProducts() [shop_product + promo_products]
    |                +--> BlockBuilder::frontRender()                  [builder blocks]
    |                +--> promoExtra() -> product_category_model       [sidebar categories]
    |                +--> defaultRender()                              [menu, footer, badges, cart]
    |                +--> defaultProductParseWithProductCodes()        [pricing, codes, gifts]
    |                |
    |                v
    |           [View Layer]
    |                |
    |                +--> promo view (standard / full-page / builder)
    |                +--> productCard component per product
    |                +--> analytics tracking data
    |
    v
HTML Response

REST API Layer (Admin)

JWT-Authenticated Request
    |
    v
[REST Router] ---> Promo::index() / show() / store() / update() / destroy()
    |                |
    |                +--> Service::all() / get()         [Read operations]
    |                +--> WriteService::create/update/delete [Write operations]
    |                +--> UploadService                    [File handling]
    |                |
    |                v
    |           [Resource Layer]
    |                +--> Resource.php (formats entity for JSON response)
    |                +--> MuiResource.php (formats translations)
    |                +--> Collection.php / MuiCollection.php
    |
    v
JSON Response

Key Dependencies

ComponentFileRole
Adv_promoecommercen/eshop/controllers/Adv_promo.phpStorefront controller
Adv_promo_adminecommercen/eshop/controllers/Adv_promo_admin.phpAdmin CRUD controller
Adv_promo_modelecommercen/eshop/models/Adv_promo_model.phpLegacy promo model
Adv_product_modelecommercen/eshop/models/Adv_product_model.phpProduct model (getPromoProducts())
BlockBuilderapplication/modules/builder/libraries/BlockBuilder.phpRich layout rendering
Pscacheapplication/libraries/Pscache.phpPSR-6 cache wrapper
Servicesrc/Domains/Product/Promo/Service.phpModern read service
WriteServicesrc/Domains/Product/Promo/WriteService.phpModern write service
Promo (REST)src/Rest/Product/Controllers/Promo.phpREST controller

Data Model

For the full shop_promo, shop_promo_mui, and shop_promo_products column schemas, see AD-07 Promotion Management.

Entity-Relationship Diagram

shop_promo (1) ---< shop_promo_mui (N)        [one-to-many: translations]
shop_promo (1) ---< shop_promo_products (N)    [junction table]
shop_promo_products (N) >--- shop_product (1)  [many-to-one: products]
TableUsage
shop_productProduct master (active, price, stock filters)
shop_product_muiProduct translations (name, slug, description by lang)
shop_vendor / shop_vendor_muiVendor data for product cards
shop_product_category_lp / shop_product_category_muiCategory slug for product URLs

Configuration

Application Config

KeyFileDefaultDescription
cart_promo_idapplication/config/main.php64Promo ID used for cart page product recommendations
cart_promo_limitapplication/config/main.php8Max products shown from cart promo
cache_l2_default_expiresapplication/config/cache.php(varies)PSCache TTL for promo and product queries
products_list_limitapp config(varies)General product listing limit

View Type Configuration

Defined in ecommercen/helpers/shopmodule_helper.php (overridable in application/helpers/main_theme_helper.php):

php
// View type labels (shown in admin dropdown)
function promoViewTypes() {
    return [0 => 'Default'];
}

// View template file mapping
function promoViews() {
    return [0 => 'promo'];  // maps to {client_views}/promo/promo.php
}

// Full-page toggle (skip site header/footer)
function promoViewFullPage() {
    return [0 => false];  // false = render inside main template
}

The promoView($viewType) helper in ecommercen/helpers/theme_helper.php resolves a view type integer to its template filename.

Template Configuration

Promo builder views are registered in application/config/mainTemplate.json and application/config/template.json:

json
{
    "promoBuilder": {
        "view": "layouts/builder/promo.php",
        "scss": ""
    }
}

REST API Policy

In application/config/rest_policies.php:

php
Promo::class => [
    'defaults' => [
        'auth' => 'backend',
        'roles' => [AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING, AUTH_ROLE_MEDIA]
    ]
]

DI Container Registration

Domain services in src/Domains/Product/container.php:

  • Promo\Repository\Repository, Promo\Repository\RepositoryConfigurator
  • Promo\Repository\MuiRepository (with NullRelationConfigurator)
  • Promo\Service, Promo\Validator, Promo\WriteService
  • Promo\Repository\WriteRepository, Promo\Repository\MuiWriteRepository

REST controller in src/Rest/Product/container.php:

  • Promo controller wired with: Service, Resource, Collection, ListRequest, WriteService, UploadService

Both registered via application/config/container/modules.php.


Client Extension Points

Storefront Controller Override

The client application controller at application/modules/eshop/controllers/Promo.php extends Adv_promo. Clients can override in their application/ directory.

promoExtra() hook: The promoExtra() method is explicitly designed as a hook point. The doc comment states it should be overridden with an empty method body (omitting parent::promoExtra()) to skip the sidebar category query. Clients can also add custom sidebar content, upsells, or promotional widgets here.

afterAdd(), afterEdit(), afterDelete(), afterProductsDelete() hooks (admin): Empty protected methods in Adv_promo_admin that clients can override for post-CRUD actions (cache clearing, sync, logging).

afterBatchActionSubmitUpdatePromo() and afterBatchActionSubmitRemovePromo() hooks (products admin): Empty protected methods in Adv_products_admin for custom logic when products are batch-added to or removed from a promo.

View Type Customization

Clients can define custom promo view types by overriding three functions in their theme helper (application/helpers/main_theme_helper.php):

php
function promoViewTypes() {
    return [
        0 => 'Default',
        1 => 'Custom Campaign',
        2 => 'Full-page Landing',
    ];
}

function promoViews() {
    return [
        0 => 'promo',
        1 => 'promo-campaign',
        2 => 'promo-landing',
    ];
}

function promoViewFullPage() {
    return [
        0 => false,
        1 => false,
        2 => true,  // renders without site header/footer
    ];
}

Each view type maps to a PHP view file at application/views/{client}/promo/{name}.php.

Builder Block Integration

By setting a builder_block_id on the promo MUI record, admins can replace the standard promo view with a fully custom block builder layout. This is per-language, allowing different layouts for different locales.

Custom REST Layer (Client Repos)

Client repos can extend the domain and REST layers in custom/:

custom/Domains/Product/Promo/...
custom/Rest/Product/Controllers/Promo.php

With DI aliases to override upstream services.

Image Storage

Promo images are stored at files/promo/ relative to the public asset directory. Both promo_image (desktop banner) and promo_small_banner (mobile banner) are uploaded via the AdvUploader library in admin or the UploadService in the REST API. The REST resource formats file paths with $this->formatFile($field, '/files/promo/').


Business Rules

  1. Product visibility: Only products with active = true AND price > 0 are shown on promo pages. Soft-deleted products are included in the query but may be filtered at the view level.

  2. Product ordering: Products are displayed in the manual order column from shop_promo_products (ascending). The admin panel supports drag-and-drop reordering via AJAX.

  3. No pagination: All matching promo products are loaded in a single query (no limit on storefront). The cart promo feature uses a limit and random ordering.

  4. Language filtering: Both product and vendor MUI records are filtered by the current language. Products without a translation in the active language will not appear.

  5. Caching: All database queries are cached via PSCache with the configured L2 cache TTL. Cache invalidation happens when the cache expires or is manually cleared.

  6. SEO control: The do_follow flag defaults to 1 (follow). When set to 0, the page emits <meta name="robots" content="noindex,nofollow"/>. This is also triggered in development/testing environments regardless of the flag.

  7. Slug uniqueness: Promo slugs are auto-generated from the name on creation (createSlug(strip_quotes($name))). On update, slugs can be manually set by users with urlFields access. The validation rule is_unique_mui ensures slug uniqueness per language.

  8. Admin access control: Promo admin requires one of: AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING, AUTH_ROLE_MEDIA. The REST API uses AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING, AUTH_ROLE_MEDIA.

  9. SEO meta access: The dofollow toggle and meta tag fields (title, description, keywords) are only visible to users with the metatags access level. Slug editing requires urlFields access.

  10. Cart promo: The order page loads a specific promo (configured via cart_promo_id) with randomized products and a configurable limit (cart_promo_limit) for cross-selling on the cart page.

  11. Batch operations: Products can be added to or removed from promos in bulk from the products admin listing, using the put_in_promo and remove_from_promo batch actions.

  12. Builder override: When a builder block is assigned to a promo's MUI record, it completely replaces the standard product grid view with the builder-rendered layout. The view type and color settings are ignored in builder mode.

  13. Analytics tracking: The main theme view calls trackProductPromotionsHelper() for each product, generating structured promotion tracking data (compatible with Google Analytics Enhanced Ecommerce).

  14. Cascading delete: Deleting a promo removes the master record, all MUI translations, and all product associations from shop_promo_products.


Wiki Guide: Builder block integration — see Builder Guide. Cache layer for promo queries — see Cache System.