• Home
  • Get help
  • Ask a question
Last post 4 hours 43 min ago
Posts last week 69
Average response time last week 18 min
All time posts 75852
All time tickets 11672
All time avg. posts per day 19

Helpdesk is open from Monday through Friday CET

Please create an (free) account to post any question in the support area.
Please check the development versions area. Look at the changelog, maybe your specific problem has been resolved already!
All tickets are private and they cannot be viewed by anyone. We have made public only a few tickets that we found helpful, after removing private information from them.

#12290 – Phoca Cart Support

Posted in ‘Pre-sale questions’
This is a public ticket. Everybody will be able to see its contents. Do not include usernames, passwords or any other sensitive information.
Monday, 16 March 2026 14:40 UTC
The Hammer

For the team or anyone else who may benefit from this component (plugin) contribution. This is a lightly tested working soltuon to get structured data running with Phoca Cart 6. The only field that isn't working and the team can sort out is the ISBN. You will also have to manually set the "Price Valid Until" field since there is no comphrensive date field to use for that other than possible discount options but that is too edge case. You can obviously set a custom field for it too. 

It would be nice to see this supported in upcoming releases of 4SEO and the ISBN field solved. 

P.S. There is a typo in the sd.php file on line 311. mnp should be "mpn"?

Don't forget to add phocacart to the hooks.php file like this:
// 3rd party
'hikashop',
'phocacart',

<?php
/**
* Project: 4SEO for Phoca Cart
*
* @package 4SEO
* @copyright Copyright John Doe
* @author John Doe
* @license GNU General Public License version 3; see LICENSE.md
* @version 0.00.0.0000
* @date 2026-01-30
*/

namespace Weeblr\Forseo\Platform\Components;

use Joomla\CMS\Factory;

use Weeblr\Forseo\Data;
use Weeblr\Forseo\Helper;

use Weeblr\Wblib\Forseo\Wb;
use Weeblr\Wblib\Forseo\System;
use Weeblr\Wblib\Forseo\Db;

// no direct access
defined('_JEXEC') || defined('WBLIB_EXEC') || die;

/**
* Support for Phoca Cart 6 on Joomla 6.
*
* Phoca Cart uses com_phocacart with views:
* - 'item' => single product detail page
* - 'category' => category listing page
* - 'items' => flat all-products listing
*/
class Phocacart extends Base
{
/**
* @var string Component name - with leading com_ removed.
*/
protected $component = 'phocacart';

/**
* @var \stdClass|null Cache for the content data captured via onContentPrepare.
*/
protected $contentData = null;

/**
* @var string[] Views that 4SEO should track/store for this component.
*/
protected $includedViews = [
'category',
'item',
'items',
];

/**
* @var null|array Layouts to include (empty array = all layouts included).
*/
protected $includedLayouts = [];

/**
* @var null|array Layouts to exclude.
*/
protected $excludedLayouts = [];

/**
* @var array Structured data types this plugin can supply.
*/
protected $supportedSdTypes = [
Data\Sd::PRODUCT,
];

/**
* @var bool No need to scan raw SEF URLs for this component.
*/
protected $filterShouldCollectUrlsFoundOnPage = false;

/**
* @var Db\Helper Convenience DB helper instance.
*/
protected $dbHelper;

// -------------------------------------------------------------------------
// Construction
// -------------------------------------------------------------------------

public function __construct($options = [])
{
parent::__construct($options);

$this->dbHelper = $this->factory->getThe('db');
}

// -------------------------------------------------------------------------
// Hook registration
// -------------------------------------------------------------------------

/**
* Register additional hooks beyond those Base already wires up.
*
* Base::addHooks() already registers:
* forseo_content_prepared -> actionStorePreparedContent
* forseo_sd_rules -> filterSdRulesWrapper -> filterSdRules
* forseo_sd_auto_data -> filterSdDataWrapper -> filterSdData
* forseo_sd_can_run_rule -> filterSdCanRunRuleWrapper -> filterSdCanRunRule
* forseo_after_route_page_data -> filterAfterRoutePageDataWrapper
* forseo_page_build_content_id -> filterPageBuildContentIdWrapper
* forseo_page_modified_at -> filterPageModifiedAtWrapper
* forseo_page_build_content_hash -> filterPageBuildContentHashWrapper
* forseo_extract_page_images_... -> filterExtractPageImagesFromContentDataWrapper
* forseo_structured_data_cleanup_... -> filterSdCleanupPatternsWrapper
*/
public function addHooks()
{
parent::addHooks();

if ($this->platform->isFrontend())
{
$this->hook->add(
'forseo_page_should_include_in_sitemap',
[$this, 'filterShouldIncludeInSitemap']
);

$this->hook->add(
'forseo_expandable_variables',
[$this, 'filterExpandableVariables']
);
}
}

// -------------------------------------------------------------------------
// Content data capture
// -------------------------------------------------------------------------

/**
* Capture Phoca Cart content data passed through onContentPrepare.
* Base handles this via isValidContentContext() which accepts any
* context starting with 'com_phocacart'.
*
* @param array $contentData
*/
public function actionStorePreparedContent($contentData)
{
parent::actionStorePreparedContent($contentData);
}

// -------------------------------------------------------------------------
// Page data filtering
// -------------------------------------------------------------------------

/**
* @param Data\Page $pageData
* @return Data\Page
* @throws \Exception
*/
protected function filterAfterRoutePageData($pageData)
{
$pageData = parent::filterAfterRoutePageData($pageData);
$inputVars = $pageData->get('input_vars', []);
$view = Wb\arrayGet($inputVars, 'view', '');

if ('item' === $view)
{
$productId = $this->getItemIdFromPageData($pageData);
if (!empty($productId))
{
$pageData->set(
'item_id',
$this->helper->compactValuesList($productId)
);
}
}

return $pageData;
}

/**
* Strip catid and Itemid from product content IDs so the same product
* reached from different categories is treated as one canonical page.
*
* @param null|array $id
* @param null|Data\Page $pageData
* @return array
* @throws \Exception
*/
protected function filterPageBuildContentId($id, $pageData)
{
$id = $this->defaultPageBuildContentId($id, $pageData);

$inputVars = $pageData->get('input_vars', []);
$view = Wb\arrayGet($inputVars, 'view', '');

if ('item' === $view)
{
unset($id['catid']);
unset($id['Itemid']);
}

$language = $pageData->get('lang');
if (!empty($language))
{
$id['lang'] = $language;
}

return $id;
}

// -------------------------------------------------------------------------
// Sitemap
// -------------------------------------------------------------------------

/**
* @param int $shouldInclude
* @param Data\Page $pageData
* @param int $sitemapType
* @return int
* @throws \Exception
*/
public function filterShouldIncludeInSitemap($shouldInclude, $pageData, $sitemapType)
{
if (!$this->shouldRunFilter($pageData))
{
return $shouldInclude;
}

$inputVars = $pageData->get('input_vars', []);
$task = Wb\arrayGet($inputVars, 'task', null);

if (!empty($task) && 'view' !== $task)
{
$shouldInclude = Data\Page::EXCLUDED;
}

return $shouldInclude;
}

// -------------------------------------------------------------------------
// Modified-at date
// -------------------------------------------------------------------------

/**
* @param null|string $lastMod
* @param Data\Page $pageData
* @return null|string
* @throws \Exception
*/
protected function filterPageModifiedAt($lastMod, $pageData)
{
$view = strtolower($pageData->get('view', ''));

if (!in_array($view, ['item', 'category'], true))
{
return $lastMod;
}

$itemId = $this->getItemIdFromPageData($pageData);
if (empty($itemId))
{
return $lastMod;
}

$dbTable = ('item' === $view) ? '#__phocacart_products' : '#__phocacart_categories';
$modCol = 'modified';
$backupCol = 'created';

$modData = $this->dbHelper->selectAssoc(
$dbTable,
[$modCol, $backupCol],
['id' => $itemId]
);

if (empty($modData))
{
return null;
}

$lastMod = Wb\arrayGet($modData, $modCol, null);
$lastMod = empty($lastMod)
? Wb\arrayGet($modData, $backupCol, null)
: $lastMod;

return $lastMod;
}

// -------------------------------------------------------------------------
// Structured data – can this SD rule run?
// -------------------------------------------------------------------------

/**
* @param bool|null $canRunRule
* @param array $spec
* @param Data\Requestinfo $requestInfo
* @param Data\Page $pageData
* @return bool|null
*/
public function filterSdCanRunRule($canRunRule, $spec, $requestInfo, $pageData)
{
if (strtolower($pageData->get('view', '')) !== 'item')
{
return false;
}

return $canRunRule;
}

// -------------------------------------------------------------------------
// Structured data – rules
// -------------------------------------------------------------------------

/**
* @param array $rules
* @param Data\Requestinfo $requestInfo
* @param Data\Page $pageData
* @param string $baseId
* @return array
* @throws \Exception
*/
protected function filterSdRules($rules, $requestInfo, $pageData, $baseId)
{
$rule = $this->factory->getA(Data\Rule::class);
$ruleData = [
'actionSdType' => [Data\Sd::PRODUCT],
'actionSdAggregateRatingAuto' => Data\Sd::FIELD_AUTO,
'actionSdReviewAuto' => Data\Sd::FIELD_AUTO,
];

$rule->set(
[
'rule' => $ruleData,
'source' => Data\Rule::SOURCE_BUILT_IN,
]
);

$rules[] = $rule;

return $rules;
}

// -------------------------------------------------------------------------
// Structured data – field values
// -------------------------------------------------------------------------

/**
* @param array $autoFieldsData
* @param array $autoFields
* @param array $spec
* @param Data\Requestinfo $requestInfo
* @param Data\Page $pageData
* @param string $baseId
* @return array
* @throws \Exception
*/
protected function filterSdData($autoFieldsData, $autoFields, $spec, $requestInfo, $pageData, $baseId)
{
$product = $this->loadProductFromPageData($pageData);
if (empty($product))
{
return $autoFieldsData;
}

/** @var Helper\Meta $metaHelper */
$metaHelper = $this->factory->getA(Helper\Meta::class);

if (array_key_exists('name', $autoFields) && !empty($product->title))
{
$autoFieldsData['sdData']['name'] = $product->title;
}

if (array_key_exists('sku', $autoFields) && !empty($product->sku))
{
$autoFieldsData['sdData']['sku'] = $product->sku;
}

// Global product identifier - written directly using schema.org property names.
if (!empty($product->isbn))
{
$autoFieldsData['sdData']['isbn'] = $product->isbn;
}
elseif (!empty($product->ean))
{
$autoFieldsData['sdData']['gtin13'] = $product->ean;
}
elseif (!empty($product->upc))
{
$autoFieldsData['sdData']['gtin12'] = $product->upc;
}
elseif (!empty($product->mpn))
{
$autoFieldsData['sdData']['mpn'] = $product->mpn;
}

// productCondition: Phoca Cart stores 0=New, 1=Used, 2=Refurbished.
if (array_key_exists('offerItemCondition', $autoFields))
{
$conditionMap = [
0 => 'NewCondition',
1 => 'UsedCondition',
2 => 'RefurbishedCondition',
];
$conditionKey = (int)$product->condition;
if (isset($conditionMap[$conditionKey]))
{
$autoFieldsData['sdData']['offerItemCondition'] = 'https://schema.org/' . $conditionMap[$conditionKey];
}
}

if (array_key_exists('description', $autoFields) && !empty($product->description))
{
$autoFieldsData['sdData']['description'] = $metaHelper->buildDescriptionFromContent(
$product->description,
[],
['abridge' => false]
);
}

if (array_key_exists('offerPrice', $autoFields) && isset($product->price))
{
$autoFieldsData['sdData']['offerPrice'] = (float)$product->price;
$autoFieldsData['sdData']['offerPriceCurrency'] = $this->getStoreCurrencyCode();
$autoFieldsData['sdData']['offerAvailability'] = ((int)$product->stock > 0)
? Data\Sd::OFFERS_IN_STOCK
: Data\Sd::OFFERS_OUT_OF_STOCK;
$autoFieldsData['sdData']['offerUrl'] = System\Route::absolutify(
$pageData->get('full_url')
);
}

if (array_key_exists('image', $autoFields))
{
$images = $this->buildSdImages((int)$product->id, $product);
if (!empty($images))
{
$autoFieldsData['sdData']['image'] = $images;
}
}

if (array_key_exists('brand', $autoFields) && !empty($product->manufacturer_title))
{
$autoFieldsData['sdData']['brand'] = $product->manufacturer_title;
}

if (array_key_exists('aggregateRating', $autoFields))
{
$rating = $this->buildAggregateRating((int)$product->id);
if (!empty($rating))
{
$autoFieldsData['sdData']['aggregateRating'] = $rating;
}
}

return $autoFieldsData;
}

// -------------------------------------------------------------------------
// Microdata cleanup
// -------------------------------------------------------------------------

/**
* @param array $patterns
* @return array
*/
protected function filterSdCleanupPatterns($patterns)
{
return array_merge(
$patterns,
[
'~(itemprop="[^"]*")? itemscope(="[^"]*")? itemtype="[^"]*"~isU',
'~<meta\s+itemprop="[^"]+"\s+content="[^"]+"\s*/?>~isU',
'~\sitemprop="[^"]+"~isU',
]
);
}

// -------------------------------------------------------------------------
// Expandable variables
// -------------------------------------------------------------------------

/**
* @param array $variables
* @param Data\Page $pageData
* @return array
* @throws \Exception
*/
public function filterExpandableVariables($variables, $pageData)
{
if (!$this->shouldRunFilter($pageData))
{
return $variables;
}

if (strtolower($pageData->get('view', '')) !== 'item')
{
return $variables;
}

$product = $this->loadProductFromPageData($pageData);
if (empty($product))
{
return $variables;
}

$newVars = [];
$newVars['product_name'] = $product->title ?? '';
$newVars['product_sku'] = $product->sku ?? '';
$newVars['product_brand'] = $product->manufacturer_title ?? '';
$newVars['product_description'] = !empty($product->description)
? strip_tags($product->description)
: '';
$newVars['product_price'] = isset($product->price)
? number_format((float)$product->price, 2) . ' ' . $this->getStoreCurrencyCode()
: '';

return array_merge($variables, $newVars);
}

// -------------------------------------------------------------------------
// Page image extraction (OGP / Twitter card)
// -------------------------------------------------------------------------

/**
* @param array $extractedImages
* @param string $context
* @param string $content
* @param object $contentObject
* @param Data\Page $pageData
* @param Data\Meta $pageMeta
* @return array
* @throws \Exception
*/
protected function filterExtractPageImagesFromContentData(
$extractedImages,
$context,
$content,
$contentObject,
$pageData,
$pageMeta
) {
static $extracted = null;

if (strtolower($pageData->get('view', '')) !== 'item')
{
return $extractedImages;
}

if (!is_null($extracted))
{
return $extracted;
}

$product = $this->loadProductFromPageData($pageData);
if (empty($product))
{
return $extractedImages;
}

$appConfig = $this->factory->getThis('forseo.config', 'app');
$imageSpec = $appConfig->get('imageDetectionRequireSizeSd');
$ogpImageSpec = $appConfig->get('imageDetectionRequireSizeOgp');
$metaHelper = $this->factory->getA(Helper\Meta::class);

$rawImages = $this->loadProductImageUrls((int)$product->id, $product);
$images = [];

foreach ($rawImages as $rawImage)
{
$url = Wb\arrayGet($rawImage, 'url', '');
$caption = Wb\arrayGet($rawImage, 'caption', '');

if (empty($url))
{
continue;
}

if (empty($images['page_image']))
{
$img = $metaHelper->validateImageFromContent($url, $imageSpec);
if (!empty($img))
{
$images['page_image'] = array_merge($img, ['alt' => $caption]);
}
}

if (empty($images['page_sharing_image']))
{
$img = $metaHelper->validateImageFromContent($url, $ogpImageSpec);
if (!empty($img))
{
$images['page_sharing_image'] = array_merge($img, ['alt' => $caption]);
}
}

if (!empty($images['page_image']) && !empty($images['page_sharing_image']))
{
break;
}
}

if (!empty($images))
{
$extracted = $images;
$extractedImages = $images;
}

return $extractedImages;
}

// -------------------------------------------------------------------------
// Content hash
// -------------------------------------------------------------------------

/**
* @param string $hash
* @param array $contentData
* @param null|Data\Page $pageData
* @return string
* @throws \Exception
*/
protected function filterPageBuildContentHash($hash, $contentData, $pageData)
{
if (strtolower($pageData->get('view', '')) !== 'item')
{
return $hash;
}

$product = $this->loadProductFromPageData($pageData);
if (empty($product))
{
return $hash;
}

return md5(
($product->id ?? '')
. ($product->title ?? '')
. strip_tags($product->description ?? '')
. ($product->metadesc ?? '')
);
}

// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------

/**
* @param Data\Page $pageData
* @return int|null
* @throws \Exception
*/
private function getItemIdFromPageData($pageData)
{
$inputVars = $pageData->get('input_vars', []);
if (empty($inputVars))
{
return null;
}

$id = Wb\arrayGetInt($inputVars, 'id', 0);

return $id > 0 ? $id : null;
}

/**
* @param Data\Page $pageData
* @return \stdClass|null
* @throws \Exception
*/
private function loadProductFromPageData($pageData)
{
$productId = $this->getItemIdFromPageData($pageData);
if (empty($productId))
{
return null;
}

return $this->loadProductById($productId);
}

/**
* @param int $productId
* @return \stdClass|null
*/
private function loadProductById($productId)
{
static $cache = [];

if (isset($cache[$productId]))
{
return $cache[$productId];
}

$cache[$productId] = null;

$db = $this->getPlatformDb();
$query = $db->getQuery(true)
->select([
$db->qn('p.id'),
$db->qn('p.title'),
$db->qn('p.alias'),
$db->qn('p.sku'),
$db->qn('p.isbn'),
$db->qn('p.upc'),
$db->qn('p.ean'),
$db->qn('p.mpn'),
$db->qn('p.condition'),
$db->qn('p.price'),
$db->qn('p.description'),
$db->qn('p.metadesc'),
$db->qn('p.stock'),
$db->qn('p.image'),
$db->qn('m.title', 'manufacturer_title'),
])
->from($db->qn('#__phocacart_products', 'p'))
->leftJoin(
$db->qn('#__phocacart_manufacturers', 'm')
. ' ON ' . $db->qn('m.id') . ' = ' . $db->qn('p.manufacturer_id')
)
->where($db->qn('p.id') . ' = ' . (int)$productId);

$db->setQuery($query);

$product = $db->loadObject();
if (!empty($product))
{
$cache[$productId] = $product;
}

return $cache[$productId];
}

/**
* Load product image URLs as ['url'=>..., 'caption'=>...] entries.
* Uses the primary image column on the product row first, then the
* gallery table.
*
* @param int $productId
* @param \stdClass|null $product
* @return array
*/
private function loadProductImageUrls($productId, $product = null)
{
static $cache = [];

if (empty($productId) || isset($cache[$productId]))
{
return $cache[$productId] ?? [];
}

$cache[$productId] = [];
$images = [];

// Primary image from product row.
if (!empty($product) && !empty($product->image))
{
$images[] = [
'url' => System\Route::absolutify(
'images/phocacartproducts/' . ltrim($product->image, '/'),
true
),
'caption' => $product->title ?? '',
];
}

// Additional gallery images from #__phocacart_product_images.
$db = $this->getPlatformDb();
$query = $db->getQuery(true)
->select([$db->qn('image')])
->from($db->qn('#__phocacart_product_images'))
->where($db->qn('product_id') . ' = ' . (int)$productId)
->order($db->qn('ordering') . ' ASC');

$db->setQuery($query);

$rows = $db->loadObjectList();
foreach ((array)$rows as $row)
{
if (empty($row->image))
{
continue;
}

$images[] = [
'url' => System\Route::absolutify(
'images/phocacartproducts/' . ltrim($row->image, '/'),
true
),
'caption' => '',
];
}

$cache[$productId] = $images;

return $cache[$productId];
}

/**
* Build the image array in the format 4SEO expects for SD injection.
*
* @param int $productId
* @param \stdClass|null $product
* @return array
*/
private function buildSdImages($productId, $product = null)
{
$rawImages = $this->loadProductImageUrls($productId, $product);
$images = [];

foreach ($rawImages as $raw)
{
$record = [
'@type' => Data\Sd::IMAGE_OBJECT,
'url' => $raw['url'],
];

if (!empty($raw['caption']))
{
$record['caption'] = $raw['caption'];
}

$images[] = $record;
}

return $images;
}

/**
* Aggregate rating from #__phocacart_reviews (published, rated rows only).
*
* @param int $productId
* @return array|null
*/
private function buildAggregateRating($productId)
{
if (empty($productId))
{
return null;
}

$db = $this->getPlatformDb();
$query = $db->getQuery(true)
->select([
'COUNT(*) AS ' . $db->qn('total'),
'AVG(' . $db->qn('rating') . ') AS ' . $db->qn('average'),
])
->from($db->qn('#__phocacart_reviews'))
->where($db->qn('product_id') . ' = ' . (int)$productId)
->where($db->qn('published') . ' = 1')
->where($db->qn('rating') . ' > 0');

$db->setQuery($query);

$data = $db->loadObject();

if (empty($data) || (int)$data->total === 0)
{
return null;
}

return [
'@type' => Data\Sd::AGGREGATE_RATING,
'ratingValue' => round((float)$data->average, 1),
'reviewCount' => (int)$data->total,
'worstRating' => 1,
'bestRating' => 5,
];
}

/**
* Resolve the store's primary currency ISO code (e.g. "USD").
*
* @return string
*/
private function getStoreCurrencyCode()
{
static $code = null;

if (!is_null($code))
{
return $code;
}

$code = 'USD';

try
{
$db = $this->getPlatformDb();
$query = $db->getQuery(true)
->select($db->qn('code'))
->from($db->qn('#__phocacart_currencies'))
->where($db->qn('published') . ' = 1')
->order($db->qn('ordering') . ' ASC')
->setLimit(1);

$db->setQuery($query);

$result = $db->loadResult();
if (!empty($result))
{
$code = (string)$result;
}
}
catch (\Exception $e)
{
// Non-fatal: leave fallback in place.
}

return $code;
}

/**
* Return the Joomla database object compatible with J4 and J6.
*
* @return \Joomla\Database\DatabaseInterface
*/
private function getPlatformDb()
{
return version_compare(\JVERSION, '4.0', '<')
? Factory::getDbo()
: Factory::getContainer()->get('db');
}
}

Monday, 16 March 2026 14:50 UTC
wb_weeblr

Hi

Thanks for this contribution.

It would be nice to see this supported in upcoming releases of 4SEO and the ISBN field solved.

I have no immediate plan to provide support for PhocaCart, which means I cannot integrate this into 4SEO as I would have to support it moving forward.

But I was under the impression Phoca Cart would generate Structured data by itself, using the Joomla native system?

Best regards,
Yannick Gaultier
https://weeblr.com | @weeblr

 

 

 

 
Monday, 16 March 2026 14:56 UTC
The Hammer

Yes, fair enough. For most people the the Joomla core structured data in Phoca Cart will be enough to manage their needs. I just like to bring silos close together or consolidate when possible.  

Monday, 16 March 2026 15:08 UTC
wb_weeblr

Hi,

Understand but as Phoca outputs already structured data, it's kinda hard to take on support for this feature in 4SEO, as supporting means updating with each new version of Phoca, watching for changes, possibly maintaining compatibility with future and past versions...

Best regards,
Yannick Gaultier
https://weeblr.com | @weeblr