Skip to content
70% launch offer · $10/hour · Get a quote

Writing upgrade-safe OpenCart 4 extensions with events

Why OCMOD stops working the day core changes, and how the event system keeps your customizations alive through updates.

NE
Nazlı Erdoğan
Software Engineer — Node.js/Python · · 12 min read

Code editor showing an event handler class

Why patching core files stops working

Every store eventually needs behaviour the core does not have: a delivery note under the add-to-cart button, a field pushed to an ERP. For a decade the standard answer was OCMOD — an XML file that finds a line of core source and splices your code around it. It works beautifully until that line changes.

Then an update rewrites the file, the search string stops matching, and the modification silently stops applying. That is the good outcome. The bad one is a near-match: the patch lands in a slightly different context and produces a page that is wrong rather than broken — which nobody notices until a customer does.

Events invert the relationship. Instead of describing where inside somebody else’s file your code should be spliced, you declare which point in the request you want to be called at, and OpenCart calls you. After an update the file may look completely different, but the trigger point is still there. That is why OpenCart 4 uses events for much of its own bundled functionality.

  • Events win wherever you need to add, wrap or replace behaviour at a defined point: a controller call, a model method, a rendered template, a loaded language file.
  • Events lose when you need to restructure markup deep inside a Twig template that has no trigger of its own — there you override the template in a child theme, or fall back to a string replacement on the output.
  • Events cost you a package: there is no single-file event, so you need an extension folder, a manifest and an install routine. For a two-line change that feels heavy — right up until the first update.

What an event actually is

An event is a row in the oc_event table: a trigger, an action, a status and a sort order. OpenCart reads the enabled rows at startup and registers each as a listener. Four families of trigger cover almost everything you will want to reach.

Stored in
oc_event
Trigger families
controller · model · view · language
Execution order
sort_order, ascending
  • Controller triggers fire around $this->load->controller() calls, which is how OpenCart renders pages and their fragments.
  • Model triggers fire around every method reached through $this->load->model(), so you can reshape data without owning the query.
  • View triggers fire around $this->load->view() — once before rendering, while the data array is still editable, and once after, with the rendered HTML in hand.
  • Language triggers fire around $this->load->language() — the clean way to add or override interface strings without shipping a full language pack.

Each family has a before and an after form, and handler arguments arrive by reference — that is the point. Changing $args in a before handler changes what the original call receives; changing $output in an after handler changes what the visitor gets. One rule deserves its own sentence: a before handler that returns a value replaces the original call entirely. Genuinely useful, and the easiest way to blank a page by accident.

Before writing a trigger string, read the ones your install already ships with:

SELECT code, `trigger`, action, sort_order
FROM oc_event
WHERE status = 1
ORDER BY `trigger`;
Copy the shape of a core row rather than typing a trigger from memory. The two details people get wrong are the application prefix (catalog/ or admin/) and the dot that separates a model method from its route. A trigger one character off never fires and never errors — it does nothing, which is the worst kind of bug to chase.

The package: one folder, one manifest

OpenCart 4 gave extensions a home. Everything you ship lives under extension/<code>/ with its own admin, catalog and system trees, plus an install.json manifest at the root. The route mirrors the folder path minus the application segment; the namespace mirrors the route in StudlyCase.

extension/stock_note/
├── install.json
├── admin/
│   ├── controller/module/stock_note.php   -> extension/stock_note/module/stock_note
│   ├── language/en-gb/module/stock_note.php
│   └── view/template/module/stock_note.twig
├── catalog/
│   ├── controller/event/product.php       -> extension/stock_note/event/product
│   └── language/en-gb/module/stock_note.php
└── system/
    └── library/stock_note/client.php

// install.json
{
  "name": "Stock Note",
  "version": "1.0.0",
  "author": "Your Company",
  "link": "https://example.com"
}

The namespace rule catches everyone once. The file extension/stock_note/catalog/controller/event/product.php declares namespace Opencart\Catalog\Controller\Extension\StockNote\Event and a class Product extending \Opencart\System\Engine\Controller. Get one segment wrong and you get a blank page and a class-not-found line in the log — read the log first, always.

Register in install(), clean up in uninstall()

OpenCart calls install() and uninstall() on your admin controller when somebody installs or removes the extension. Event rows are created and deleted there and nowhere else: an event registered by hand in SQL survives an uninstall and haunts the next developer.

<?php
namespace Opencart\Admin\Controller\Extension\StockNote\Module;

class StockNote extends \Opencart\System\Engine\Controller {
    public function install(): void {
        $this->load->model('setting/event');

        $this->model_setting_event->addEvent([
            'code'        => 'stock_note',
            'description' => 'Delivery note on the product page',
            'trigger'     => 'catalog/view/product/product/before',
            'action'      => 'extension/stock_note/event/product.note',
            'status'      => 1,
            'sort_order'  => 1
        ]);
    }

    public function uninstall(): void {
        $this->load->model('setting/event');
        $this->model_setting_event->deleteEventByCode('stock_note');
    }
}
OpenCart 4.0.0 and 4.0.1 shipped addEvent() with positional arguments; 4.0.2 and later take the array shown above. Open admin/model/setting/event.php and match the signature you actually have. This is the most common reason a copied tutorial quietly does nothing.

Use one code per package and delete by that code on uninstall. Several triggers under the same code is fine and preferred: one delete removes all of them, and a reinstall can never leave duplicate listeners behind — the classic cause of a note that prints twice.

Writing the handler

A handler is an ordinary controller in the catalog tree. Its signature depends on the trigger family: before handlers take the route and the arguments; after handlers also take the output. Parameter names are yours; the order is not.

Here is the clean version of a delivery note. It runs before the template renders, while the data array can still be edited, so the theme decides where the value appears:

<?php
namespace Opencart\Catalog\Controller\Extension\StockNote\Event;

class Product extends \Opencart\System\Engine\Controller {
    // catalog/view/product/product/before
    public function note(string &$route, array &$data): void {
        $product_id = (int)($this->request->get['product_id'] ?? 0);

        if (!$product_id) {
            return;
        }

        $this->load->language('extension/stock_note/module/stock_note');
        $this->load->model('catalog/product');

        $product = $this->model_catalog_product->getProduct($product_id);

        if ($product && (int)$product['quantity'] > 0) {
            $data['ships_in'] = $this->language->get('text_ships_today');
        } else {
            $data['ships_in'] = $this->language->get('text_backorder');
        }
    }
}

The child theme then prints {{ ships_in }} wherever it belongs. Two habits keep this safe: read the id from the request rather than trusting a key to exist in $data, and return early instead of assuming.

Changing markup without touching a template

Sometimes you cannot edit the template — a commercial theme you do not own, updated every few months. Then you take the after form of the same trigger and work on the rendered string. It is fast, legitimate, and the most fragile technique in this article — the anchor you search for is exactly what a theme update changes.

// catalog/view/product/product/after
public function noteAfter(string &$route, array &$data, string &$output): void {
    $anchor = '<div id="product">';

    if (!str_contains($output, $anchor)) {
        return;   // theme changed - fail quietly, never fatally
    }

    $html = '<p class="ships-in">' . $this->language->get('text_ships_today') . '</p>';

    $output = str_replace($anchor, $anchor . $html, $output);
}

Keep the anchor short and structural, do the replacement once, and make a missing anchor a silent no-op. A handler that throws inside a view event takes the whole page with it.

When the event does not fire

Events fail silently by design, so debug them in a fixed order instead of guessing:

  1. 1Confirm the row exists and is enabled: SELECT * FROM oc_event WHERE code = 'stock_note';
  2. 2Compare your trigger string character by character with a core row that works.
  3. 3Check that the action resolves: the route must point at a real file under extension/<code>/, and the part after the dot must be a public method.
  4. 4Clear system/storage/cache and the theme cache. OpenCart caches more between requests than most people expect.
  5. 5Make sure the extension is installed, not merely uploaded — check oc_extension and oc_extension_install, not the admin list.
  6. 6Put an error_log(__METHOD__) as the first line of the handler. If it never prints, the problem is registration, not your logic.
  7. 7If two extensions touch the same output, look at sort_order. The later listener wins the string replacement, and that ordering is the whole bug.

Where events are the wrong tool

Events are not a universal answer; treating them as one produces slow stores. Four limits worth respecting:

  • Storefront handlers run on every matching page view. If yours calls an external API, you have put somebody else’s uptime inside your checkout path — write to a queue and process it out of band.
  • Events cannot create a database column, a route or an admin menu entry — that is install() work, in the same package.
  • A before handler that returns a value replaces the original call. Use it deliberately for overrides, never as a shortcut.
  • Two extensions rewriting the same HTML is a merge conflict at runtime. Prefer data-level triggers over output-level ones whenever the template gives you the option.

Done well, the payoff is quiet: you update OpenCart, the note still shows, and nobody spends a Saturday re-applying patches.

When to write to us

If you are carrying a stack of OCMOD patches that break on every update, converting them to events is a contained, estimable piece of work. We work at the launch rate of $10 per hour plus VAT, give you the hours before anything starts, and answer first messages within two hours between 09:00 and 22:00, Monday to Saturday. Send the modification list and your OpenCart version.

NE
Nazlı Erdoğan
Software Engineer — Node.js/Python

API services in Node.js and Python; test-driven development.