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 realidproperty and no__isset()to consult, so PHP answers honestly: not set. -
is_array($product)→ false.ArrayAccessmakes 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:
- a
Productobject, - a
ProductLazyArray, - a plain array from
getTemplateVarProduct(), - 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 ArrayAccessbeforeis_array, both before property access; -
make "not found" loud during development. A resolver that returns
0and callers that early-return on0is a machine for hiding this. Even a temporaryPrestaShopLogger::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.
Top comments (0)