TYPO3

Replacing realurl with site configuration, slugs and redirects

6 min read

Goodbye realurl, hello slugs

Hi folks,

When I moved my own site hoja.in from TYPO3 7.6 to a fresh TYPO3 14.3 project this year, one extension worried me more than the others: realurl. For years it built every speaking URL of the site. In TYPO3 14 it simply does not exist, and since 9.5 the core has its own routing with site configuration and page slugs. The code part of the move is easy. The scary part is the URLs that Google, old emails and other people’s bookmarks still point to. They must not break.

In this post I show what I did: read what realurl really produced, let the core generate slugs, keep the old paths alive with redirects, and check the status codes with curl instead of trusting my eyes.

Problems I faced

realurl had a habit of hiding its decisions. Mine used realurl_autoconf.php, the file realurl writes itself, so nobody had looked at it for years. Before deleting anything I opened it from the legacy copy:

$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['realurl'] = [
    'hoja.in' => [
        'init' => [
            'appendMissingSlash' => 'ifNotFile,redirect',
            'emptyUrlReturnValue' => '/',
        ],
        'pagePath' => ['rootpage_id' => '6'],
        'fileName' => [
            'defaultToHTMLsuffixOnPrev' => 0,
            'acceptHTMLsuffix' => 1,
        ],
    ],
];

Three facts came out of it. The root page was uid 6. URLs ended with a slash, like /about/. And realurl also accepted /about.html, even if it never generated it. So the new site has to answer for three shapes of the same page.

Step 1: the site configuration

In TYPO3 14 the site lives in config/sites/main/config.yaml. For hoja.in it is short, because the site is one language and has no plugins with URL parameters:

rootPageId: 6
base: 'https://hoja.in/'
baseVariants:
  -
    base: 'https://myport.ddev.site/'
    condition: 'applicationContext == "Development/Ddev"'
websiteTitle: 'hoja.in'
dependencies:
  - harvisoft/sitepackage
languages:
  -
    title: English
    enabled: true
    languageId: 0
    base: /
    locale: en_US.UTF-8
    hreflang: en-US

The baseVariants entry is what lets the same file work in DDEV and on the server. No route enhancers are needed here. If your old realurl config appended .html to every page and you want to keep that, the core has the PageType decorator for it:

routeEnhancers:
  PageTypeSuffix:
    type: PageType
    default: '.html'
    map:
      '.html': 0

I did not use it. Keeping a suffix forever only to match an old extension felt wrong when the old site itself did not generate it.

Step 2: generate the slugs like the backend does

The content came over with an import script, not with the seven-hop upgrade chain, so the pages.slug field started empty. I did not want to invent slugs by hand. TYPO3 has SlugHelper, which is the same class the backend uses when an editor saves a page. The import script calls it for every page and then makes the result unique inside the site:

$slugHelper = GeneralUtility::makeInstance(
    SlugHelper::class,
    'pages',
    'slug',
    $GLOBALS['TCA']['pages']['columns']['slug']['config']
);
foreach ($pageUids as $uid) {
    $record = BackendUtility::getRecord('pages', $uid);
    $slug = $uid === ROOT_PAGE ? '/' : $slugHelper->generate($record, (int)$record['pid']);
    $state = RecordStateFactory::forName('pages')
        ->fromArray($record, (int)$record['pid'], $uid);
    $slug = $slugHelper->buildSlugForUniqueInSite($slug, $state);
    $connection->update('pages', ['slug' => $slug], ['uid' => $uid]);
}

The real script fetches the record with a QueryBuilder without restrictions, because hidden pages need slugs too, but the idea is the same. Because the slug config uses the page title, “About” became /about, which is exactly the old path without the slash. Lucky me.

Step 3: redirects for everything else

EXT:redirects stores redirects in the table sys_redirect, and its middleware runs before the page is resolved. That means a redirect wins even if a page with the same path exists. For a handful of URLs you can use the Redirects backend module. For a list from an old sitemap or a log file, I prefer a small command, so the mapping lives in a CSV file in git and can be reviewed.

<?php

declare(strict_types=1);

namespace HarvisoftSitepackageCommand;

use SymfonyComponentConsoleAttributeAsCommand;
use SymfonyComponentConsoleCommandCommand;
use SymfonyComponentConsoleInputInputArgument;
use SymfonyComponentConsoleInputInputInterface;
use SymfonyComponentConsoleOutputOutputInterface;
use TYPO3CMSCoreDatabaseConnectionPool;
use TYPO3CMSRedirectsServiceRedirectCacheService;

#[AsCommand('sitepackage:redirects:import', 'Creates 301 redirects from a CSV file: old path, page uid')]
final class ImportRedirectsCommand extends Command
{
    public function __construct(
        private readonly ConnectionPool $connectionPool,
        private readonly RedirectCacheService $redirectCacheService,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addArgument('file', InputArgument::REQUIRED, 'CSV file');
        $this->addArgument('host', InputArgument::OPTIONAL, 'Source host', '*');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $connection = $this->connectionPool->getConnectionForTable('sys_redirect');
        $host = (string)$input->getArgument('host');
        $created = 0;

        $file = new SplFileObject((string)$input->getArgument('file'));
        $file->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::READ_AHEAD);
        $file->setCsvControl(',', '"', '');

        foreach ($file as $row) {
            [$path, $pageUid] = array_map('trim', $row) + [1 => ''];
            if ($path === '' || str_starts_with($path, '#') || (int)$pageUid === 0) {
                continue;
            }
            $exists = $connection->count('uid', 'sys_redirect', [
                'source_host' => $host,
                'source_path' => $path,
                'deleted' => 0,
            ]);
            if ($exists > 0) {
                continue;
            }
            $connection->insert('sys_redirect', [
                'pid' => 0,
                'createdon' => time(),
                'updatedon' => time(),
                'source_host' => $host,
                'source_path' => $path,
                'target' => 't3://page?uid=' . (int)$pageUid,
                'target_statuscode' => 301,
                'force_https' => 1,
            ]);
            $created++;
        }

        // Redirects are cached per host; rows inserted by SQL are not seen until the cache is rebuilt
        $this->redirectCacheService->rebuildForHost($host);
        $output->writeln(sprintf('%d redirects created.', $created));

        return Command::SUCCESS;
    }
}

Two details matter. The target is a t3://page link, so the redirect still works when somebody renames the page later. And the redirect cache must be rebuilt, otherwise you insert rows and wonder why nothing happens. The command is registered by its #[AsCommand] attribute as long as the extension has the usual Configuration/Services.yaml with autowire and autoconfigure enabled.

The CSV is boring on purpose:

# old path, page uid
/about.html,7
/resume.html,10
/contact.html,13
ddev exec vendor/bin/typo3 sitepackage:redirects:import redirects.csv

Run it against a copy with a fresh dump first. Writing to the database is the one step git cannot undo.

Step 4: check status codes, not screenshots

A page that “looks fine” in the browser can still be a 404 with a nice error page, or a 302 where you wanted a 301. So I ask curl:

for path in /about /about/ /about.html /contact/ '/index.php?id=7'; do
  printf '%-18s ' "$path"
  curl -sk -o /dev/null -w '%{http_code} %{redirect_url}n' "https://myport.ddev.site$path"
done

Before the redirects, my local copy answered like this: /about, /about/, /contact/ and /index.php?id=7 with 200, and /about.html with 404. The trailing slash version still resolves, so the old realurl links keep working, and the canonical tag tells search engines which URL counts (on hoja.in the section pages even point to the one-page root, because that is where their content is shown). The .html variant is honestly still open on my list: realurl accepted it, but I have no proof anybody ever linked to it. The CSV above is ready for the day the server log shows those requests.

The bigger check was the smoke test. Before touching anything, I captured status code, size and title of the old 7.6 site’s URLs as a baseline, and after the move the same URLs were compared against it. All of them answered 200 with the expected titles. That baseline gave me more peace of mind than any amount of clicking.

What I would do again

  • Read the realurl config before deleting the extension. It tells you which URL shapes exist in the wild.
  • Let SlugHelper generate slugs instead of writing them by hand.
  • Keep the old-path mapping in a file in git and import it with a command.
  • Prove the result with curl and a baseline, not with a screenshot.

If this post helped you, I will be really happy. Cheers!

Further reading

Leave a comment

Your email address will not be published.