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.