Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

isset($obj->id) is false and is_array($obj) is false — the object that is neither

Here is a resolver that looks defensive and correct: private function extractProductId($product) { if (is_object($product) && isset($product->id)) { return (int) $product->id; } if (is_array($produ

Here is a resolver that looks defensive and correct:

private function extractProductId($product)
{
    if (is_object($product) && isset($product->id)) {
        return (int) $product->id;
    }
    if (is_array($product) && isset($product['id_product'])) {
        return (int) $product['id_product'];
    }

    return 0;
}

Object shape, array shape, safe fallback. It handles everything a template might hand you.

Except it does not, and the way it fails is invisible.

The object that is neither

PrestaShop 8 and 9 pass products to display hooks as a ProductLazyArray, which extends AbstractLazyArray. That class:

  • implements ArrayAccess, so $product['id_product'] works;
  • does not declare __get() or __isset().

Work through the resolver with one of those:

  • is_object($product) → true
  • isset($product->id) → false. There is no real id property and no __isset() to consult, so PHP answers honestly: not set.
  • is_array($product) → false. ArrayAccess makes the bracket syntax work; it does not make the value an array. is_array() tests the actual type.

Both branches decline. The function returns 0.

Why you get no error

Because 0 is a perfectly good value to return, and every caller treats it as "no product here":

public function hookDisplayProductAdditionalInfo($params)
{
    $id = $this->extractProductId($params['product']);
    if (!$id) {
        return '';          // <- every hook, every page
    }
    ...
}

An empty string from a display hook is indistinguishable from a hook that had nothing to say. So:

  • no exception,
  • no log entry,
  • no PHP notice — isset() is specifically the function that does not warn,
  • the page renders perfectly, minus your block.

We shipped a product-reviews feature this way. Enabled, configured, correct, and rendering nowhere. The star ratings, the review list, the summary — all gone, on every product page, with the module reporting itself healthy.

The fix

Read through ArrayAccess first, and check offsetExists rather than property access:

private function extractProductId($product)
{
    if (!$product) {
        return 0;
    }

    // Scalar id passed directly.
    if (is_numeric($product)) {
        return (int) $product;
    }

    // ArrayAccess FIRST: covers ProductLazyArray and plain arrays alike.
    if ($product instanceof ArrayAccess || is_array($product)) {
        foreach (['id_product', 'id'] as $key) {
            if (isset($product[$key]) && $product[$key]) {
                return (int) $product[$key];
            }
        }
    }

    // A real Product object.
    if (is_object($product) && isset($product->id)) {
        return (int) $product->id;
    }

    return 0;
}

And a fallback that costs nothing, because the URL dispatcher always knows:

if (!$id) {
    $id = (int) Tools::getValue('id_product');
}

That last line is not laziness. A theme can call a hook from a context where the product variable is a summary array with a different key set, and the request parameter is the one thing that is reliably correct on a product page.

Four shapes, one test

A theme can hand a hook a product in at least four forms, depending on the PrestaShop version and how the template was written:

  1. a Product object,
  2. a ProductLazyArray,
  3. a plain array from getTemplateVarProduct(),
  4. an integer id.

Once you know that, the regression test writes itself, and it needs no PrestaShop:

class FakeLazyArray implements ArrayAccess {
    private $d;
    public function __construct(array $d) { $this->d = $d; }
    public function offsetExists($o): bool { return isset($this->d[$o]); }
    #[\ReturnTypeWillChange]
    public function offsetGet($o) { return $this->d[$o] ?? null; }
    public function offsetSet($o, $v): void { $this->d[$o] = $v; }
    public function offsetUnset($o): void { unset($this->d[$o]); }
}

ok(extractProductId(new FakeLazyArray(['id_product' => 42])) === 42, 'lazy array');
ok(extractProductId(['id_product' => 42])                    === 42, 'plain array');
ok(extractProductId((object) ['id' => 42])                   === 42, 'object');
ok(extractProductId(42)                                      === 42, 'scalar');

Twelve lines of fake, four assertions, no framework. The fake is the important half: it reproduces the exact property that broke us — bracket access works, property access does not — which the real class would also do, if you could load it without booting a shop.

The generalisation

ArrayAccess gives you the syntax of an array without the type of an array, and a class can implement it without implementing the magic methods that make property access work. Any check of the form

is_object($x) ? $x->foo : $x['foo']

has a hole for exactly that class of object, and the hole is silent because isset() never warns.

If you are writing a resolver for "some shape of thing a caller might pass me":

  • branch on capability, not on type — instanceof ArrayAccess before is_array, both before property access;
  • make "not found" loud during development. A resolver that returns 0 and callers that early-return on 0 is a machine for hiding this. Even a temporary PrestaShopLogger::addLog() on the fallback path would have saved us a week;
  • write the fake. You cannot test against the real lazy array without the framework, and the property that breaks you is trivial to reproduce.

Found while adding product reviews to a testimonials module at MEG Venture. The hooks were registered, the settings were on, and the feature was invisible on every product page in the catalogue.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.