Search by

cpsit / cps-shortnr

cps

routing alias enhancer over middleware

Package info

github.com/CPS-IT/cps_shortnr

Type:typo3-cms-extension

pkg:composer/cpsit/cps-shortnr

Statistics

Installs: 1 533

Dependents: 0

Suggesters: 0

Stars: 3

Open Issues: 0

4.1.1 2026-11-03 19:18 UTC

README

Routing alias enhancer via middleware for TYPO3 CMS. Generates and resolves short URLs for pages and plugin records using configurable patterns.

Requirements

  • PHP ^8.2
  • TYPO3 ^13.4

Dependencies

Installation

composer require cpsit/shortnr

The extension auto-registers its middleware and cache configuration.

Configuration

Default Configuration

The extension ships with a minimal default configuration in Configuration/config.yaml:

shortNr:
  _default:
    notFound: "/"
    languageParentField: "l10n_parent"
    languageField: "sys_language_uid"
    identifierField: "uid"

  pages:
    type: page
    table: pages
    pattern: "p{uid:int(min=1)}(-{sys_language_uid:int(min=0,default=0)})"

Pattern Syntax

Patterns use the typed-pattern-engine syntax:

Syntax Description
{field:int(min=1)} Integer with minimum value
{field:int(min=0,default=0)} Integer with default
(-{field}) Optional segment (parentheses)
PREFIX{field} Static prefix

Example: NEWS{uid:int(min=1)}(-{sys_language_uid:int(min=0,default=0)}) generates NEWS123 or NEWS123-1

Adding Custom Configuration

Create an event listener to register your configuration file:

<?php
namespace Vendor\SitePackage\EventListener\ShortNr;

use CPSIT\ShortNr\Event\ShortNrConfigPathEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;

#[AsEventListener(
    identifier: 'site-package/shortNrConfigListener',
    event: ShortNrConfigPathEvent::class,
    method: 'loadConfig'
)]
class ConfigLoadingEventListener
{
    public function loadConfig(ShortNrConfigPathEvent $event): void
    {
        $event->addConfigPath('EXT:site_package/Configuration/cps_shortnr.yaml');
    }
}

Config Merging Behavior

Configs are merged via array_replace_recursive - higher priority configs (default: 10) override lower ones (extension default: -1000).

  • Every field can be overwritten by later configs
  • Arrays merge recursively (add new keys, override existing)
  • Set a field to null to remove it
  • the priority can be altered at the ShortNrConfigPathEvent::addConfigPath call

Configuration Examples

Page type:

shortNr:
  pages:
    type: page
    table: pages
    pattern: "PAGE{uid:int(min=1)}(-{sys_language_uid:int(min=0,default=0)})"

Plugin type (e.g., News):

shortNr:
  news:
    type: plugin
    table: tx_news_domain_model_news
    pattern: "NEWS{uid:int(min=1)}(-{sys_language_uid:int(min=0,default=0)})"
    plugin:
      extension: News
      plugin: Pi1
      pid: 43           # Page UID containing the plugin
      action: detail
      controller: News
      objectName: news  # Request parameter name

With conditions:

shortNr:
  event:
    type: plugin
    table: tx_news_domain_model_news
    pattern: "EVENT{uid:int(min=1)}(-{sys_language_uid:int(min=0,default=0)})"
    plugin:
      extension: News
      plugin: Pi1
      pid: 474
      action: detail
      controller: News
      objectName: news
    condition:
      is_event: 1

Available Condition Operators

See Configuration/config.example.full.yaml for detailed examples.

Operator Example Description
implicit field: value Equals
eq field: {eq: value} Equals (explicit)
in field: [a, b, c] Value in array
contains field: {contains: "text"} Substring match
starts field: {starts: "prefix"} Starts with
ends field: {ends: "suffix"} Ends with
gt, gte field: {gte: 50} Greater than (or equal)
lt, lte field: {lt: 100} Less than (or equal)
between field: {between: [10, 50]} Range
isset field: {isset: true} Field exists
not field: {not: {eq: value}} Negation

Usage

ViewHelper

Generate short URLs in Fluid templates:

<html xmlns:sn="http://typo3.org/ns/CPSIT/ShortNr/ViewHelpers"
      data-namespace-typo3-fluid="true">

<!-- By config name and UID -->
<sn:shortUrl name="news" uid="123" output="result" absolute="1">
    <f:if condition="{result.uri}">
        <a href="{result.uri}">{result.uri}</a>
    </f:if>
</sn:shortUrl>

<!-- From current page context (no name/uid = environment demand) -->
<sn:shortUrl output="shortNr" absolute="1">
    {shortNr.uri}
</sn:shortUrl>

<!-- From domain object -->
<sn:shortUrl object="{newsItem}" output="result" absolute="1">
    {result.uri}
</sn:shortUrl>

ViewHelper Arguments:

Argument Type Description
output string Variable name for result (required)
languageUid int Target language
absolute bool Generate absolute URL

The ViewHelper supports three demand modes (mutually exclusive):

Mode Arguments Description
Config + UID name + uid Explicit config name and record UID
Object object Domain object (extracts config + UID automatically)
Environment (none) Uses current page/plugin context

Service (PHP)

Encoding (generate short URL):

use CPSIT\ShortNr\Service\EncoderService;
use CPSIT\ShortNr\Service\Url\Demand\Encode\ConfigNameEncoderDemand;
use CPSIT\ShortNr\Service\Url\Demand\Encode\ObjectEncoderDemand;
use CPSIT\ShortNr\Service\Url\Demand\Encode\EnvironmentEncoderDemand;

// Inject EncoderService via constructor
public function __construct(private readonly EncoderService $encoderService) {}

// 1. Config + UID: Explicit config name and record UID
$demand = (new ConfigNameEncoderDemand('news', 123))
    ->setRequest($request)      // Required for site context
    ->setLanguageId(0)          // Target language (null = from request)
    ->setAbsolute(true);        // Full URL vs relative path
$uri = $this->encoderService->encode($demand);

// 2. Object: From domain object (AbstractEntity)
$demand = (new ObjectEncoderDemand($newsEntity))
    ->setRequest($request)
    ->setAbsolute(true);
$uri = $this->encoderService->encode($demand);

// 3. Environment: From current request context
$demand = (new EnvironmentEncoderDemand(
    $request->getQueryParams(),
    $request->getAttribute('frontend.page.information')?->getPageRecord() ?? [],
    $request->getAttribute('routing'),
    $request->getAttribute('extbase')
))
    ->setRequest($request)
    ->setAbsolute(true);
$uri = $this->encoderService->encode($demand);

Decoding (resolve short URL):

use CPSIT\ShortNr\Service\DecoderService;
use CPSIT\ShortNr\Service\Url\Demand\Decode\DecoderDemand;

// Inject DecoderService via constructor
public function __construct(private readonly DecoderService $decoderService) {}

// Direct: From short URL string
$demand = new DecoderDemand('NEWS123');
$fullUri = $this->decoderService->decode($demand);

// From request: Extracts short URL from request path
$demand = $this->decoderService->getDecoderDemandFromRequest($request);
$fullUri = $demand ? $this->decoderService->decode($demand) : null;

How It Works

  1. Middleware intercepts GET requests and checks for short URL patterns
  2. DecoderService matches request path against configured patterns
  3. On match: 301 redirect to resolved full URL
  4. On no match: request continues to TYPO3 routing

Encoding compiles record data into short URL using configured pattern. Decoding extracts UID from short URL, queries database with conditions, generates full URL.

Which record a short number serves

Decoding runs before backend authentication, so it answers for an anonymous visitor:

  • Only visible records count: not deleted, not hidden, starttime reached, endtime not passed (TCA enablecolumns), live workspace only. Encoding ignores visibility, so editors can copy the short link of a record before publishing it.
  • The short number names a visible translation in the requested language: exactly that one. Otherwise, several visible translations in one language (left-over drafts): the lowest uid wins.
  • The base record (the row without language parent, usually the default language) is not visible: not found, also for its translations. Records that exist only in another language (free mode, l10n_parent = 0) are their own base record.
  • Pages: sysfolders and spacers are never a target.
  • The requested language has no visible translation: languageFallback decides (fallback = the base record in its own language).
shortNr:
  _default:
    languageFallback: true    # default: serve the base record in its own language
  press:
    languageFallback: false   # this item only: missing translation = not found

Caching

  • Decoded and encoded URLs are cached for at most one week in the TYPO3 cache cps_shortnr.
  • A decode entry expires early at the next starttime/endtime of the record or one of its translations (embargoed records become reachable on time). A notFound answer lives at most one hour (safety net for writes no invalidation sees).
  • The decode cache key is built from the parsed values: p0200 and p200 share one entry.
  • Entries are tagged per table they read and dropped when such a record changes:
    • DataHandler saves (backend, any command): automatic
    • Extbase persistence (importers, forms): automatic, once per table at the end of the request
    • plain SQL writers must call GeneralUtility::makeInstance(ShortNrCacheInvalidator::class)->invalidateTable('my_table') Only tables used by a config item are flushed.
  • Cache tags: all, uri, encode, decode, tree, shortnr_table_<table>. cache:flush and "flush frontend caches" clear everything.
  • The default backend is FileBackend: one cache per web node, and every tag flush reads every cache file. With several web nodes or many short numbers, configure a shared backend:
    $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['cps_shortnr']['backend']
        = \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class; // or Typo3DatabaseBackend
  • Compiled config is cached in var/cache/code/cps_shortnr and rebuilt when a YAML file changes. A broken YAML is logged once (error), short numbers pass through to TYPO3 until it is fixed; the failure is retried every 5 minutes.

NotFound Handling

Configure fallback behavior when a short URL cannot be resolved:

shortNr:
  _default:
    notFound: 2           # Page UID
    # or
    notFound: "/404"      # URI string
    # or
    notFound: ""          # Disable (continue to TYPO3 routing, TYPO3 answers 404)

A not found is logged at debug level only (bots probing number ranges are normal); query errors and broken processor configuration are logged as errors.

Extension Setup

The extension registers:

  • PSR-15 middleware (early in stack, before site resolution)
  • TYPO3 cache configuration (cps_shortnr)
  • DataHandler hook for cache invalidation