Writing a custom auditor
Add your own crawl-budget detector by implementing one interface and adding one DI entry.
The interface
namespace Byte8\SeoSuite\Model\IndexBudget;
interface AuditorInterface
{
public function getCode(): string;
/** @return AuditFinding[] */
public function audit(?int $limit = null): array;
}
getCode() is the unique identifier shown in the grid and accepted by the --auditor CLI flag. Use snake_case.
audit() returns an array of AuditFinding value objects. Empty array = nothing wrong. Should never throw — wrap exceptions internally and either skip or emit an info-severity finding describing the failure.
The AuditFinding value object
new AuditFinding(
auditor: 'my_custom_auditor',
severity: AuditFinding::SEVERITY_WARNING, // error | warning | info
code: 'specific_issue_code',
entityType: 'product', // or category, cms_page, config, store
storeId: 1,
targetId: 1234,
message: 'Product XYZ has no canonical override despite being in 3 categories.',
url: '/xyz.html',
recommendation: 'Set the product canonical via byte8_seosuite_url, or enable product_canonical_tag.'
);
Or use the named constructors:
AuditFinding::error('my_auditor', 'code', 'product', 1, 1234, 'Message', '/url', 'Fix');
AuditFinding::warning(...);
AuditFinding::info(...);
Recipe — duplicate-product-name auditor
Suppose you want to detect products that share a name (often a sign of bad imports or copy-paste errors that hurt SEO).
namespace Vendor\Module\SeoSuite\Auditor;
use Byte8\SeoSuite\Model\IndexBudget\AuditFinding;
use Byte8\SeoSuite\Model\IndexBudget\AuditorInterface;
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory;
use Magento\Store\Model\StoreManagerInterface;
class DuplicateProductNames implements AuditorInterface
{
public const CODE = 'duplicate_product_names';
public function __construct(
private readonly StoreManagerInterface $storeManager,
private readonly CollectionFactory $productCollectionFactory
) {
}
public function getCode(): string
{
return self::CODE;
}
public function audit(?int $limit = null): array
{
$findings = [];
foreach ($this->storeManager->getStores() as $store) {
$storeId = (int) $store->getId();
$byName = [];
$products = $this->productCollectionFactory->create()
->addStoreFilter($storeId)
->addAttributeToSelect(['entity_id', 'name', 'sku']);
if ($limit) {
$products->setPageSize($limit);
}
foreach ($products as $product) {
$name = trim((string) $product->getName());
if ($name === '') continue;
$byName[$name][] = ['id' => (int) $product->getId(), 'sku' => (string) $product->getSku()];
}
foreach ($byName as $name => $entries) {
if (count($entries) < 2) continue;
foreach ($entries as $entry) {
$findings[] = AuditFinding::warning(
self::CODE,
self::CODE,
'product',
$storeId,
$entry['id'],
sprintf('Product "%s" (SKU %s) shares its name with %d other product(s).',
$name, $entry['sku'], count($entries) - 1),
null,
'Differentiate the names with model number, colour, or size — duplicate names dilute SERP relevance signals.'
);
}
}
}
return $findings;
}
}
Register in etc/di.xml:
<type name="Byte8\SeoSuite\Model\IndexBudget\AuditorPool">
<arguments>
<argument name="auditors" xsi:type="array">
<item name="duplicate_product_names" xsi:type="object">Vendor\Module\SeoSuite\Auditor\DuplicateProductNames</item>
</argument>
</arguments>
</type>
The named-array merge means built-in auditors keep working — your auditor adds to the pool. After setup:di:compile, your auditor:
- Runs whenever Run Audit is clicked or
seosuite:index:auditis run (no-aflag) - Can be run alone via
seosuite:index:audit -a duplicate_product_names - Shows up in the grid's Auditor filter dropdown
- Is included in the dashboard widget's index-budget count
Adding an auto-fixer
If your auditor's findings have a deterministic fix, add it to Byte8\SeoSuite\Model\IndexBudget\IssueFixer:
public function fix(IndexIssueInterface $issue): void
{
switch ($issue->getCode()) {
case OutOfStock::CODE:
$this->fixOutOfStock($issue);
break;
case DisabledIndexable::CODE:
$this->fixDisabledRewrite($issue);
break;
case DuplicateProductNames::CODE:
// Your fix logic here
break;
default:
throw new LocalizedException(__('No automatic fix exists for code "%1".', $issue->getCode()));
}
$issue->setResolved(true);
$this->issueResource->save($issue);
}
Then update Byte8\SeoSuite\Ui\Component\Listing\Columns\IndexIssueActions to include your code in FIXABLE_CODES. The grid will start showing the Apply Fix button on your rows.
(For a future version we'll move IssueFixer to a strategy pool so this doesn't require editing core. For now the small monkey-patch is the supported way.)
Performance tips
- Use raw SQL for auditors that scan the whole catalog —
DisabledIndexableis a good template - Respect
--limit— it's the user's escape hatch when an auditor is too slow. Always cap your queries withsetPageSize($limit)at minimum - Don't load full product objects when you only need a couple of attributes —
addAttributeToSelect(['entity_id', 'sku'])is much faster than the default
Testing
$auditor = $objectManager->get(\Vendor\Module\SeoSuite\Auditor\DuplicateProductNames::class);
$findings = $auditor->audit(50);
foreach ($findings as $f) print_r($f->toArray());
Or end-to-end via the CLI:
bin/magento seosuite:index:audit -a duplicate_product_names -l 50 --format json