Skip to content

Promotion Management (Admin)

Flow ID: AD-07 Module(s): eshop Complexity: Medium Last Updated: 2026-04-04

Business Context

Promotions are curated product landing pages used for marketing campaigns. Each promotion has MUI content (name, description, footer), visual customization (header/text colors, banner images), SEO metadata, optional page builder block integration, and an ordered collection of assigned products. Promotions serve as themed product groups displayed on the storefront at /promo/{slug} and can also surface as cart-page cross-sell suggestions.

Entry Points

TypePath / TriggerControllerMethod
AdminList all promosecommercen/eshop/controllers/Adv_promo_admin.phpindex()
AdminCreate promosameadd()
AdminEdit promosameedit($id)
AdminDelete promosamedelete($id)
AdminManage productssameproducts($promoId)
AdminRemove single productsameproducts_delete($promoId, $id)
AdminBatch remove productssamebatchRemoveFromPromo($promoId)
AdminReorder productssameupdate_products_order() (AJAX)
BatchAssign products to promoAdv_products_admin.phpbatchActionSubmit() put_in_promo
BatchRemove products from promoAdv_products_admin.phpbatchActionSubmit() remove_from_promo
RESTFull CRUDsrc/Rest/Product/Controllers/Promo.phpindex, show, item, store, update, destroy

Authorization

Access restricted to roles: AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING, AUTH_ROLE_MEDIA.

SEO fields (meta_title, meta_keywords, meta_description, do_follow) require the metatags permission. Slug editing on update requires the urlFields permission.

Admin Menu

Promo admin appears under the MARKETING group in the admin menu (application/config/admin_menu.php), route promo_admin. A separate cache-clear entry exists at advisable/clearcache/promo.

Routes

Admin routes (application/config/routes.php):

promo_admin           → eshop/promo_admin/index
promo_admin/(.+)      → eshop/promo_admin/$1

Storefront routes (same file):

promo/(.+)            → eshop/promo/index/$1

REST routes (application/config/rest_routes.php):

GET    /rest/product/promo           → index (collection)
GET    /rest/product/promo/item      → item (single by filter)
GET    /rest/product/promo/{id}      → show
POST   /rest/product/promo           → store (create)
POST   /rest/product/promo/{id}      → update
DELETE /rest/product/promo/{id}      → destroy

Data Model

Tables

TablePurposeKey Columns
shop_promoMaster recordid, promo_image, promo_small_banner, view_type (tinyint, default 0), header_color, text_color, do_follow (default 1)
shop_promo_muiTranslations per languagepromo_id FK, slug, name, description, footer_description, meta_title, meta_keywords, meta_description, lang, builder_block_id FK
shop_promo_productsProduct assignmentspromo_id FK, product_id FK, order (default 99999)

Indexes

  • shop_promo_mui: slug, promo_id, composite (promo_id, lang), composite (slug, lang), builder_block_id
  • shop_promo_products: product_id, promo_id, composite (promo_id, product_id), order

Core Operations

Create Promo (add())

  1. Run validation() -- form validation rules for all languages
  2. On submit, upload files via advuploader->uploadFilesSameFolder() to files/promo/
  3. Build master data: view_type, promo_image, promo_small_banner, header_color, text_color, do_follow (if metatags permission)
  4. Build MUI data per language: slug (auto-generated via createSlug()), name, description, footer_description, builder_block_id, plus SEO fields if permitted
  5. Insert via promo_model->add_record($data, $data_mui) -- inserts master then loops MUI
  6. Call afterAdd($id) hook (empty by default, overridable in client repos)
  7. Redirect to listing with success flash

Edit Promo (edit($id))

  1. Handle image deletion requests (del_image, del_small_banner) before loading record
  2. Load record via promo_model->getAdminRecord($id) -- returns ['master' => ..., 'details' => [...]]
  3. On submit, same upload + validation flow as create
  4. Slug handling: if user has urlFields permission and provides a slug, use it; otherwise auto-generate only if no slug exists yet (preserves existing slugs)
  5. Update via promo_model->update_record($data, $dataMui, $id) -- uses updateOrInsertMui() for upsert semantics
  6. Call afterEdit($id) hook
  7. Redirect to listing

Delete Promo (delete($id))

Cascade delete: removes master row from shop_promo, all translations from shop_promo_mui, and all product assignments from shop_promo_products. Calls afterDelete($id) hook.

Manage Products (products($promoId))

Lists all products assigned to the promo with sortable drag-and-drop UI. Query joins shop_promo_products with shop_product and shop_product_mui for product name and image in the current admin language. Products are ordered by shop_promo_products.order ASC.

Actions available:

  • Drag-and-drop reorder: update_products_order() -- AJAX handler using jQuery sortable; updates order column for each product position
  • Remove single product: products_delete($promoId, $id) -- deletes by shop_promo_products.id
  • Batch remove: batchRemoveFromPromo($promoId) -- accepts checkbox-selected product_id[] array, deletes matching rows for the promo

Batch Product Assignment (via Products Admin)

From the product listing (Adv_products_admin), batch actions support:

  • put_in_promo: Calls promo_model->update_batch_product_promo($products, $promoId) -- inserts each product into the promo if not already assigned (duplicate-safe check)
  • remove_from_promo: Calls promo_model->batchRemoveProductPromo($product_ids, $promo_id) -- bulk delete by product IDs within the promo

Both actions have overridable hooks: afterBatchActionSubmitUpdatePromo(), afterBatchActionSubmitRemovePromo().


Validation Rules

FieldRuleNotes
view_typetrim, numericLayout variant selector
header_colortrimHex color string
text_colortrimHex color string
name_{lang}trim, required, is_unique_mui[shop_promo_mui.name...]Unique per language; on update uses promo_id exclusion
description_{lang}trimRich text (TinyMCE)
footer_description_{lang}trimRich text
meta_title_{lang}trimMetatags permission required
meta_keywords_{lang}trimMetatags permission required
meta_description_{lang}trimMetatags permission required
slug_{lang}trimurlFields permission required; only on edit

View Templates

ViewPathPurpose
Listapplication/views/admin/promo/list.phpTable with ID, image thumbnail, name, storefront URL, view type, actions (edit/products/delete)
Createapplication/views/admin/promo/create.phpMultipart form with MUI tabs, general info, SEO (collapsible), descriptions (tabbed: main + footer), builder block selector
Editapplication/views/admin/promo/update.phpSame as create with pre-populated values
Productsapplication/views/admin/promo/products.phpSortable product table with image, name, product ID, checkbox bulk select, individual remove

View Type System

View types control the storefront template used for promo rendering. Configured via helper functions in application/helpers/main_theme_helper.php:

  • promoViewTypes() -- display labels (e.g., 0 => 'Default')
  • promoViews() -- template file names (e.g., 0 => 'promo')
  • promoViewFullPage() -- whether the view renders as full-page or within the standard layout

Client repos override these functions to add custom promo layouts (e.g., buy-the-look, suncare campaigns).

Builder Block Integration

When the block builder is enabled, each language can have a builder_block_id linking to a reusable content block. The admin form provides a dropdown selector with create/edit/refresh/clear buttons. When a builder block is assigned, the storefront renders the promoBuilder layout instead of the standard promo template.


Modern Domain Layer

Namespace: Advisable\Domains\Product\Promo

Entity (Repository/Entity.php)

Extends BaseEntity, implements FilterTranslation. Properties: id, promo_image, promo_small_banner, view_type, header_color, text_color, do_follow.

MUI Entity (Repository/MuiEntity.php)

Properties: id, promo_id, slug, meta_title, meta_keywords, meta_description, name, description, footer_description, lang, builder_block_id.

Repository Relations (RepositoryConfigurator.php)

php
'translations' => new Relation(Relation::ONE_TO_MANY, MuiRepository::class, 'promo_id'),
'products'     => new Relation(Relation::MANY_TO_MANY, Product\Repository\Repository::class,
                               'promo_id', 'shop_promo_products', 'product_id'),

Service (Service.php)

Implements ReadService with Specification pattern: all(ListRequest) for paginated collections, item(ListRequest) for single match, get($id, $relations) for direct fetch.

WriteService (WriteService.php)

Full CRUD with transactional operations:

  • create(array $data) -- validates via WriteData/Validator, inserts master + translations, returns entity with translations loaded
  • update(int|string $id, array $data) -- partial update; null fields excluded; translations replaced (delete + re-insert)
  • delete(int|string $id) -- removes translations then master in transaction

WriteData / MuiWriteData

Value objects with OpenAPI schema annotations. Accept both snake_case and camelCase field names. WriteData maps: promoImage, promoSmallBanner, viewType, headerColor, textColor, doFollow. MuiWriteData maps: lang, slug, metaTitle, metaKeywords, metaDescription, name, description, footerDescription, builderBlockId.

Validator

Validates lang is present on each translation. Create and update validation hooks exist but are currently open (no additional rules enforced beyond translations).

ListRequest

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


REST API Layer

Namespace: Advisable\Rest\Product\Controllers\Promo

Controller

Extends HandlesRestfulActions, uses HandlesUploadActions trait. Supports file uploads for promo_image and promo_small_banner to files/promo/. Full CRUD with OpenAPI annotations.

Resources

ClassSchema NameDescription
Resource.phpProductPromoResourceid, image (formatted file path), smallBanner, viewType, headerColor, textColor, doFollow (bool), translations, products
Collection.phpProductPromoCollectionArray of Resource
MuiResource.phpProductPromoMuiResourceid, promoId, slug, metaTitle, metaKeywords, metaDescription, name, description, footerDescription, lang, builderBlockId
MuiCollection.phpProductPromoMuiCollectionArray of MuiResource

The Resource class includes relation handling: translations via MuiCollection, products via Product\Collection.

DI Registration

Domain (src/Domains/Product/container.php): Registers Repository, RepositoryConfigurator, MuiRepository (with NullRelationConfigurator), Service, WriteRepository, MuiWriteRepository, Validator, WriteService.

REST (src/Rest/Product/container.php): Registers Promo controller with service, resource/collection classes, ListRequest, WriteService, and UploadService.


Cart-Page Promo Feature

A special promo can be designated as the "cart promo" via config (application/config/main.php):

php
$config['cart_promo_id'] = 64;     // Promo ID to pull products from
$config['cart_promo_limit'] = 8;   // Max products to show

Adv_order::cartPromo() loads this promo's products (randomized, limited) and renders them as cross-sell suggestions on the cart/checkout page.


Integration Tests

Path: tests/Integration/Domains/Product/Promo/

Test FileCoverage
RepositoryTest.phpget(), match() with filters/sorts/pagination, matchOne(), count(), relation loading (translations), MuiRepository queries
ServiceTest.phpall() with pagination, item(), get() with relations, WriteService create/update/delete, translations CRUD, round-trip lifecycle

Client Extension Points

Hook MethodLocationPurpose
afterAdd($id)Adv_promo_adminPost-create logic (e.g., cache invalidation, sync)
afterEdit($id)Adv_promo_adminPost-update logic
afterDelete($id)Adv_promo_adminPost-delete logic
afterProductsDelete($id)Adv_promo_adminPost product-removal logic
afterBatchActionSubmitUpdatePromo()Adv_products_adminAfter batch assign to promo
afterBatchActionSubmitRemovePromo()Adv_products_adminAfter batch remove from promo
promoViewTypes()main_theme_helper.phpCustom view type labels
promoViews()main_theme_helper.phpCustom template file names
promoViewFullPage()main_theme_helper.phpFull-page vs. wrapped layout
promoExtra()Adv_promo (front)Additional sidebar/data for storefront

Key Files

FilePurpose
ecommercen/eshop/controllers/Adv_promo_admin.phpAdmin controller (CRUD + product management)
ecommercen/eshop/models/Adv_promo_model.phpLegacy model (queries, batch operations)
ecommercen/eshop/controllers/Adv_promo.phpStorefront controller
application/views/admin/promo/Admin view templates (list, create, update, products)
application/helpers/main_theme_helper.phpView type configuration functions
src/Domains/Product/Promo/Modern domain layer (Entity, Repository, Service, WriteService, Validator, WriteData, MuiWriteData, ListRequest)
src/Rest/Product/Controllers/Promo.phpREST controller with OpenAPI annotations
src/Rest/Product/Resources/Promo/REST resources (Resource, Collection, MuiResource, MuiCollection)
src/Domains/Product/container.phpDomain DI registration
src/Rest/Product/container.phpREST DI registration
application/config/routes.phpAdmin and storefront route definitions
application/config/rest_routes.phpREST API route definitions
tests/Integration/Domains/Product/Promo/Integration tests (RepositoryTest, ServiceTest)