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 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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.