Magento 2 – Redirect 404 Product URLs to an Assigned Product Listing Page.

We recently had some severe issues with 404 product URLs on google SEO. Around 25K URLs were broken in the google search console. As the site is fully automated and has around one million products, It imports products from different sources via product feeds and external API. And exports products to Google automatically on a daily basis. 

As a result, many products get out of stock and are disabled sometimes. So, the number of 404 URLs was increasing daily in the search console.

Initially, I thought of the general URL redirection method base on the web server for resolving the issue. But It didn’t work because of the massive amount of URLs.

I had to find other ways, As the URLs are dynamic and products may come back in stock or enable again; so the redirection should not have happened.

So, I had to depend on Magento instead of doing it from Apache/NGINX. After finding some blogs and digging deep into the codebase, I found the controller_action_predispatch_cms_noroute_index event. It fires only before the 404 URLs are dispatched, not for live URLs. So, It’s the place where I need to put my redirection logic.

All I needed to do is, checking the URL type was product URL or not. If it’s a product URL, redirect to an assigned category or product listing page.

Declare the event for the front-end area only.

app/code/MilanDev/Fix404/etc/frontend/events.xml

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
    <event name="controller_action_predispatch_cms_noroute_index">
        <observer name="milandev_no_route_to_live_page" instance="MilanDev\Fix404\Observer\NotFoundToLive" />
    </event>
</config>


Here is the rest of the logic.

app/code/MilanDev/Fix404/Observer/NotFoundToLive.php

<?php
declare(strict_types=1);

namespace MilanDev\Fix404\Observer;

use Magento\Catalog\Helper\Category;
use Magento\Framework\App\ActionInterface;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\CategoryRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Store\Model\StoreManagerInterface;
use Magento\UrlRewrite\Model\ResourceModel\UrlRewriteCollectionFactory as CollectionFactory;
use Magento\Framework\App\ActionFlag;

class NotFoundToLive implements ObserverInterface
{
    /**
     * @var ProductRepositoryInterface
     */
    private $productRepository;
    /**
     * @var CollectionFactory
     */
    private $collectionFactory;
    /**
     * @var CategoryRepositoryInterface
     */
    private $categoryRepository;
    /**
     * @var Category
     */
    private $categoryHelper;
    /**
     * @var ActionFlag
     */
    private $actionFlag;
    /**
     * @var StoreManagerInterface
     */
    private $storeManager;

    /**
     * @param ProductRepositoryInterface $productRepository
     * @param CategoryRepositoryInterface $categoryRepository
     * @param Category $categoryHelper
     * @param CollectionFactory $collectionFactory
     * @param ActionFlag $actionFlag
     * @param StoreManagerInterface $storeManager
     */
    public function __construct(
        ProductRepositoryInterface $productRepository,
        CategoryRepositoryInterface $categoryRepository,
        Category $categoryHelper,
        CollectionFactory $collectionFactory,
        ActionFlag $actionFlag,
        StoreManagerInterface $storeManager
    ) {
        $this->productRepository = $productRepository;
        $this->collectionFactory = $collectionFactory;
        $this->categoryRepository = $categoryRepository;
        $this->categoryHelper = $categoryHelper;
        $this->actionFlag = $actionFlag;
        $this->storeManager = $storeManager;
    }

    /**
     * @param Observer $observer
     * @return $this
     */
    public function execute(Observer $observer)
    {
        $reqUri = trim($observer->getEvent()->getRequest()->getRequestUri(), '/');
        $baseUrl = $this->storeManager->getStore()->getBaseUrl();

        $reqUriArr = explode('/', $reqUri);
        if (count($reqUriArr) > 1) {
            $reqUri = end($reqUriArr);
        }

        $urlRewriteCollection = $this->collectionFactory->create()
            ->addFieldToSelect(['request_path', 'target_path'])
            ->addFieldToFilter('request_path', $reqUri);

        $targetPath = $urlRewriteCollection->getFirstItem()->getTargetPath();
        $productId = 0;

        if ($targetPath && strpos($targetPath, 'catalog/product/view') !== false) {
            /**
             * Redirect to products category
             */
            $targetPathArr = explode('/', $targetPath);
            $productId = end($targetPathArr);

            $categories = [];
            try {
                if ($product = $this->productRepository->getById($productId)) {
                    $categories = $product->getCategoryIds();
                }
                $categoryId = reset($categories);
                if ($categoryId && ($categoryObj = $this->categoryRepository->get($categoryId))) {
                    $catUrl = $this->categoryHelper->getCategoryUrl($categoryObj);
                    $this->actionFlag->set('', ActionInterface::FLAG_NO_DISPATCH, true);
                    $observer->getControllerAction()->getResponse()->setRedirect($catUrl);
                }
            } catch (NoSuchEntityException $e) {
            }
        }

        return $this;
    }
}

There could be many other ways to accomplish such issues. Please feel free to comment below if you know of any other ways to address this issue or have any opinions. Happy Coding.