Prejsť na obsah

AI blog · DotApp PHP Framework 2.0

How Extender works in DotApp PHP Framework

Dotsystems\App\Parts\Extender is an opt-in, request-local replacement registry. It is not Events, module.{mod}.{name}.hook, middleware, or trigger with veto. A method is extendable only when its owner checks Extender::exists(), calls Extender::call(...), and handles the result. An ordinary result is final. Extender::original() is the one explicit signal that asks the owner to continue its original logic; the owner recognizes it with Extender::isOriginal(). There is no next() chain. Loyalty extends Shop methods. In Listeners::register() do not call Extender::extend() directly. Only subscribe: Events::on('dotapp.module.shop.loading', …) and call extend() inside that callback with a controller string such as Loyalty:Pricing@quote!. Matching listeners run before any module constructs, so the subscription exists before Shop boots. Event names are lowercase. .loading fires only when Shop actually loads, still before Shop load_libraries() and initialize(), so the replacement exists even if Shop uses the extension point inside initialize(). Do not wait for dotapp.module.shop.loaded — that fires after Shop’s initialize(). This article is a complete Shop checkout quote plus a Loyalty listener that does not boot Loyalty’s pages.

Common mistakes

Wrong Right
Events::on / trigger / triggerWithVeto to replace a method Extender::extend plus the owner’s exists() / call()
Call Extender::extend() in Loyalty initialize(), or wait for dotapp.module.shop.loaded Wake the listener on Shop URLs. Subscribe to dotapp.module.shop.loading and call extend() there. Loyalty’s module map can stay empty
Call Extender::extend() directly in Listeners::register(), or on init.start register() only attaches Events::on('dotapp.module.shop.loading', …). Direct extend() would bind even if Shop later skips load. init.start fires before initializeCondition
Omit Listeners::initializeRoutes() and expect Shop URLs Null inherits this module’s map, not Shop. List the known Shop prefixes. ['*'] is the broader, weaker variant — only if Shop can load on an arbitrary URL
Pass a Closure or [Class, 'method'] as the handler Prefer a string: 'Loyalty:Pricing@quote!'
Pass $request, tokens, CRC, or request bodies into call() Pass ids, flags, already-safe scalars the owner already checked
Always run Shop’s original logic after call() Return an ordinary replacement result. Continue only when isOriginal() recognizes the unique original() marker
Return or serialize the original() marker Keep the marker inside the owner method; test it with isOriginal() and continue locally
Invent next() or a public string sentinel One handler may return Extender::original(); object identity cannot collide with a real result
A second extend() for the same class+method One replacement. The duplicate throws \LogicException
Patch DACore (or Shop, from Loyalty) to insert Extender::call Only the owner of the target method opts in
exist() in new code exists() is canonical. exist() is only the alias

When to use Extender

Use it when Shop deliberately allows one other loaded module to replace a meaningful output for this request: page or block HTML, a cart, an export, an invoice, or a checkout quote. Do not sprinkle extension points over ordinary persistence, CRC, decryption, pagination internals, or every helper “just in case”. Do not use it to log, sync, or refuse a delete — those stay on Events and triggerWithVeto. Do not use it as a plugin bus. One handler replaces the method. Two modules fighting for the same target is a boot error, not a merge.

Why listeners and .loading, not initialize() and not .loaded

Matching module.listeners.php files always register before any matching module constructs. Shop’s constructor then fires init.start, evaluates initializeCondition, optionally load() (loadingload_libraries()initialize()loaded), then init.end. Event names are lowercased on register and trigger, so write dotapp.module.shop.loading. .loading is the canonical Extender hook: it fires only when Shop actually decided to load, and it is still before Shop load_libraries() and initialize(), so the extension point exists during Shop init. dotapp.module.shop.loaded is after Shop’s initialize(). If Shop calls the extension point during init, a .loaded callback is already too late. init.start is earlier than initialize(), but it fires before initializeCondition is evaluated — Shop may still skip load. Prefer .loading. Direct Extender::extend() inside Loyalty Listeners::register() is early enough, but it is not canonical: the replacement would register just because the listener route matched, even when Shop’s initializeCondition later skips load. Bind extend() to Shop .loading. register() itself only calls Events::on — no query, log, HTTP, or file I/O. If Loyalty called extend() from its own initialize(), Shop might initialize first (map order) and miss the replacement. Loyalty does not need Shop prefixes on Module::initializeRoutes(). An extender-only module can return [] there. Cover Shop on Listeners::initializeRoutes()Independent listener routes. Prefer the known Shop URL masks. Use ['*'] only if Shop can be loaded dynamically on an arbitrary URL — that is the broader, weaker variant.

  1. Loyalty listener matches a Shop URL. register() only calls Events::on('dotapp.module.shop.loading', …).
  2. Shop constructs. init.start fires. initializeCondition may still skip load — no extender yet, correctly.
  3. If Shop loads, .loading fires and the callback calls Extender::extend(..., 'Loyalty:Pricing@quote!'). Then load_libraries() and initialize() may already call the extension point — the extender exists.
  4. The router dispatches. Checkout::quote still sees the replacement.

API

Call Returns / throws
Extender::extend($className, $methodName, $handler) void. Invalid target or handler → \InvalidArgumentException. Duplicate target → \LogicException
Extender::exists($className, $methodName) bool. Canonical probe. Invalid identifier → \InvalidArgumentException
Extender::exist($className, $methodName) Alias of exists(). Prefer exists() in new code
Extender::call($className, $methodName, ...$arguments) The replacement’s return value, unchanged. No handler, or re-entry into the same target → \LogicException. Handler throwables propagate
Extender::original() A unique request-local object marker asking the owner to continue its original logic
Extender::isOriginal($result) bool. True only for the exact marker returned by original()

$className is a fully qualified PHP class (leading \ stripped; the class is not autoloaded at register time). $methodName is a PHP method name. Keys are case-insensitive. The registry lives for this request only. On every request register() must subscribe again; extend() then runs from the .loading callback when Shop actually loads.

original() uses object identity, not a public string or integer constant, so it cannot collide with a legitimate handler result. Never send that object to HTTP or JSON. The owner checks it and continues locally. A replacement that may defer should not declare a narrow return type such as : array; document array|object in PHPDoc instead.

Prefer a controller string Module:Controller@method! — validated with stringToCallable(), invoked with DotApp::call() (trailing ! skips DI, same grammar as routes — Callable strings). A native PHP callable is accepted but is the weaker form: it can load classes too early and is harder to read in a listener file.

Shop owns the extension point

Shop decides Checkout::quote may be replaced. The HTTP action still does CRC, decrypts ids, and checks rights. It then calls Checkout::quote($cartId, $subtotal) with explicit safe arguments — not $request.


namespace Dotsystems\App\Modules\Shop\Controllers;

use Dotsystems\App\Parts\Controller;
use Dotsystems\App\Parts\Extender;

class Checkout extends Controller
{
    /**
     * CRCchecking — none
     * Returns the checkout quote for this cart. Opt-in Extender target.
     *
     * @param int $cartId Cart id already loaded and authorized by the caller.
     * @param string $subtotal Decimal subtotal already computed by Shop.
     * @return array{cart_id: int, subtotal: string, total: string}
     */
    public static function quote(int $cartId, string $subtotal): array
    {
        if (Extender::exists(self::class, 'quote')) {
            $result = Extender::call(self::class, 'quote', $cartId, $subtotal);
            if (!Extender::isOriginal($result)) {
                return $result;
            }
        }

        // No replacement exists, or Loyalty explicitly requested Shop's maintained default.
        return [
            'cart_id' => $cartId,
            'subtotal' => $subtotal,
            'total' => $subtotal,
        ];
    }
}
    

Handler exceptions propagate. The action’s catch still reports the catch bus — Error handling and return values. An ordinary replacement result returns immediately. Only isOriginal($result) continues Shop’s implementation. Never return or serialize the marker. Do not merge an ordinary result with the original, or call quote() again from the replacement (recursion throws \LogicException).

Loyalty registers from listeners before Shop initializes

File: app/modules/Loyalty/module.listeners.php. register() only attaches Events::on — same class of work as any other listener: no query, log, HTTP, or file I/O. Extender::extend() belongs inside the dotapp.module.shop.loading callback, not in register() itself. After the listener masks change: php dotapper.php --optimize-modules. Do not return ['*'] just because Shop exists; list Shop’s known prefixes. Use ['*'] only if Shop can load on an arbitrary URL.


<?php
namespace Dotsystems\App\Modules\Loyalty;

use Dotsystems\App\DotApp;
use Dotsystems\App\Parts\Events;
use Dotsystems\App\Parts\Extender;

class Listeners extends \Dotsystems\App\Parts\Listeners
{
    /**
     * Wake this listener where Shop can load. Loyalty pages stay on Module::initializeRoutes().
     *
     * @return array<int, string> Listener route masks.
     */
    public function initializeRoutes()
    {
        return [
            '/Shop', '/Shop/*',
            '/api/v1/auth/Shop', '/api/v1/auth/Shop/*',
            '/api/v1/noauth/Shop', '/api/v1/noauth/Shop/*',
        ];
    }

    public function register($dotApp)
    {
        Events::on('dotapp.module.shop.loading', function ($module) {
            Extender::extend(
                \Dotsystems\App\Modules\Shop\Controllers\Checkout::class,
                'quote',
                'Loyalty:Pricing@quote!'
            );
        });
    }
}

new Listeners(DotApp::DotApp());
    

A second module calling extend() for Checkout::quote throws. That conflict is intentional. An extender-only Loyalty can return [] from Module::initializeRoutes(). If Loyalty also has pages, keep those prefixes on the module map — never copy Shop there just for Extender.

Replacement handler

File: app/modules/Loyalty/Controllers/Pricing.php. Invoked only via Extender, not as an HTTP route. The string Loyalty:Pricing@quote! is the preferred handler.


namespace Dotsystems\App\Modules\Loyalty\Controllers;

use Dotsystems\App\Parts\Controller;
use Dotsystems\App\Parts\Extender;

class Pricing extends Controller
{
    /**
     * CRCchecking — none
     * Replacement Shop checkout quote. Invoked only via Extender.
     *
     * @param int $cartId Cart id passed explicitly by Shop.
     * @param string $subtotal Decimal subtotal passed explicitly by Shop.
     * @return array{cart_id: int, subtotal: string, total: string}|object Quote or Extender original marker.
     */
    public static function quote(int $cartId, string $subtotal)
    {
        if ((float) $subtotal <= 0.0) {
            return Extender::original();
        }

        $total = number_format(((float) $subtotal) * 0.95, 2, '.', '');

        return [
            'cart_id' => $cartId,
            'subtotal' => $subtotal,
            'total' => $total,
        ];
    }
}
    

FAQ

Why not fire an event and let Loyalty return a new total?

trigger() ignores listener returns. triggerWithVeto() only stops an action with a Veto object — it does not replace a method result. Extender is the replacement API.

Loyalty never replaced the quote. Why?

The Loyalty listener did not load on that URL. Cover Shop on Listeners::initializeRoutes(), then re-run --optimize-modules. Do not add Shop prefixes to Loyalty’s module map just for Extender. If Shop is loaded later via module('Shop')->load() on a URL Loyalty never listed, use ['*'] on the listener — not on the module map.

Why not extend() inside Loyalty initialize(), directly in register(), on init.start, or on shop.loaded?

Modules initialize in map order. Shop can finish boot before Loyalty’s initialize() runs. dotapp.module.shop.loaded fires after Shop’s initialize(), so it is too late if Shop already called the extension point. Direct extend() in Listeners::register() is early, but it is not canonical: the listener route can match and still Shop’s initializeCondition skips load. dotapp.module.shop.init.start is before initialize(), but also before that condition — Shop may still skip. Subscribe in register() with Events::on('dotapp.module.shop.loading', …) and call extend() there. .loading means Shop actually decided to load, and it is still before load_libraries() and initialize().

Can two modules extend the same method?

No. The second extend() throws. Choose one owner of the replacement, or make Shop call two named methods.

How can Loyalty leave one case to Shop?

Return Extender::original(). Shop must store the result of call(), test it with Extender::isOriginal($result), and continue its local implementation only for that marker. There is no next(), and the marker must never become an HTTP or JSON response.

Do I add Extender to .hooks?

No. .hooks documents module.{mod}.{name}.hook and veto contracts. Extender itself has no event name. You do subscribe to Shop’s lifecycle: dotapp.module.shop.loading is the canonical place to call extend(). Do not list that as a Shop hook in .hooks.

Does Loyalty:Pricing@quote! receive injected services?

No. Trailing ! skips DI, same as a hot route. Construct what you need inside the method. Dependency injection.

See also