Architecture
The gap this extension fills
TYPO3's page cache is invalidated by two mechanisms.
- Event-driven invalidation
- A cache entry is dropped when the data behind it changes, for example when an editor saves a page.
- Tag-based invalidation
flushdrops every entry carrying a given tag.By Tag ()
Neither reacts to the passage of time.
A page or content element with starttime or endtime changes its visibility at a
fixed moment without anybody editing a record, so no invalidation is triggered.
The cache entry keeps the visibility snapshot taken at render time until its relative
lifetime runs out.
Render at 09:00 Cache entry written with a relative lifetime
├─ element A: hidden (starttime 10:00 has not been reached)
└─ element B: visible
10:00 Nothing edits a record, so nothing invalidates
the entry. Element A stays hidden until the
lifetime expires.
The extension closes that gap by shortening the page cache lifetime so it ends at the next temporal transition instead of at an arbitrary later moment.
Entry point: the cache lifetime event
TYPO3 dispatches TYPO3\ while a page
cache entry is being written.
Netresearch\ is registered for exactly
that event class in Configuration/Services.yaml, and it is the extension's only
frontend hook.
final class TemporalCacheLifetime
{
public function __construct(
private readonly ExtensionConfiguration $extensionConfiguration,
private readonly ScopingStrategyInterface $scopingStrategy,
private readonly TimingStrategyInterface $timingStrategy,
private readonly Context $context,
private readonly LoggerInterface $logger
) {
}
public function __invoke(ModifyCacheLifetimeForPageEvent $event): void
{
$lifetime = $this->timingStrategy->getCacheLifetime($this->context, $event->getPageId());
if ($lifetime !== null) {
$maxLifetime = $this->determineMaxLifetime($event->getRenderingInstructions());
$event->setCacheLifetime(\min($lifetime, $maxLifetime));
}
}
}
The listener runs no queries of its own.
It asks the active timing strategy for a lifetime and caps the answer.
A null lifetime — what the scheduler timing strategy always returns — leaves TYPO3's
own lifetime untouched.
The whole __ body is wrapped in a try/catch .
A failing strategy is logged as an error and the page renders with TYPO3's lifetime; it
never breaks page rendering.
Cap on the calculated lifetime
determine resolves the upper bound in this order:
cache_from the rendering instructions, if it is set and greater than zero (TypoScriptperiod config.)cache_ period advanced.from the extension configuration, if greater than zerodefault_ max_ lifetime 86400
The listener also caps the value the timing strategy already capped, because
Dynamic limits its own result to advanced.
independently.
The result never exceeds the lifetime TYPO3 hands in with the event
(Modify), which already reflects records
expiring earlier, config. and other listeners: the listener takes the
minimum of both whenever the incoming value is greater than zero.
Registration
services:
Netresearch\TemporalCache\EventListener\TemporalCacheLifetime:
public: true
tags:
- name: event.listener
identifier: 'temporal-cache/modify-cache-lifetime'
event: TYPO3\CMS\Frontend\Event\ModifyCacheLifetimeForPageEvent
method: '__invoke'
Strategy selection and wiring
Two independent strategy families decide what happens:
- Scoping strategy (
Scoping)Strategy Interface - Answers which records a transition lookup covers and which cache tags a transition
flushes.
Implementations:
Global,Scoping Strategy Per,Page Scoping Strategy Per.Content Scoping Strategy - Timing strategy (
Timing)Strategy Interface - Answers when the invalidation happens — through a shortened cache lifetime, through a
background scheduler run, or a mix of the two.
Implementations:
Dynamic,Timing Strategy Scheduler,Timing Strategy Hybrid.Timing Strategy
Both interfaces extend Netresearch\, which
declares the single method get.
How a strategy is activated
Every strategy is registered as a service carrying one of two tags:
nr_temporal_ cache. scoping_ strategy nr_temporal_ cache. timing_ strategy
Scoping and Timing receive all services carrying
their family's tag through Symfony's !tagged_:
Netresearch\TemporalCache\Service\Scoping\ScopingStrategyFactory:
public: true
arguments:
$strategies: !tagged_iterator 'nr_temporal_cache.scoping_strategy'
$extensionConfiguration: '@Netresearch\TemporalCache\Configuration\ExtensionConfiguration'
Netresearch\TemporalCache\Service\Scoping\ScopingStrategyInterface:
alias: Netresearch\TemporalCache\Service\Scoping\ScopingStrategyFactory
public: false
Both factories share the selection logic in the trait
Netresearch\:
private function selectNamedStrategy(
iterable $strategies,
string $configuredName,
string $emptyMessage
): NamedStrategyInterface {
$firstStrategy = null;
foreach ($strategies as $strategy) {
$firstStrategy ??= $strategy;
if ($strategy->getName() === $configuredName) {
return $strategy;
}
}
return $firstStrategy ?? throw new RuntimeException($emptyMessage);
}
The configured name is scoping. respectively timing. from the
extension configuration.
Three consequences follow from this code:
- The tags carry no
identifierattribute. A strategy is identified by itsgetreturn value alone.Name () - When no name matches, the first tagged service wins.
GlobalandScoping Strategy DynamiccarryTiming Strategy priority: 100inConfiguration/Services.yamlso that they come first and hold that fallback. - When a family has no tagged service at all, the factory throws a
Runtime.Exception
Each factory implements its own family interface and delegates every call to the selected
strategy, and the interface name is aliased to the factory.
Anything type-hinting Scoping or Timing
therefore receives the factory and, through it, the configured strategy.
Adding a strategy from another extension
Because the factories iterate a tag rather than a hard-coded list, a strategy declared in another extension needs nothing but the tag:
namespace MyVendor\MyExtension\Scoping;
use Netresearch\TemporalCache\Domain\Model\TemporalContent;
use Netresearch\TemporalCache\Service\Scoping\ScopingStrategyInterface;
use TYPO3\CMS\Core\Context\Context;
final class RootlineScopingStrategy implements ScopingStrategyInterface
{
public function getCacheTagsToFlush(TemporalContent $content, Context $context): array
{
return ['pageId_' . $content->pid];
}
public function getNextTransition(Context $context, ?int $pageId = null): ?int
{
return null;
}
public function getName(): string
{
return 'rootline';
}
}
services:
MyVendor\MyExtension\Scoping\RootlineScopingStrategy:
tags:
- { name: 'nr_temporal_cache.scoping_strategy' }
Setting scoping. to rootline then activates it.
Note
Inside this extension, autoregistration excludes Service/ and
Service/, so its own strategies need explicit service
definitions.
An extension with the default autoconfigure setup only has to add the tag.
Transition lookups
All temporal queries live in
Netresearch\
(contract: Temporal).
Three methods feed the strategies:
// Earliest transition across every monitored table (site-wide)
public function getNextTransition(
int $currentTimestamp,
int $workspaceUid = 0,
int $languageUid = 0
): ?int;
// Earliest transition in the pages table only
public function getNextPageTransition(
int $currentTimestamp,
int $workspaceUid = 0,
int $languageUid = 0
): ?int;
// Earliest transition in the content tables, restricted to one pid
public function getNextContentTransitionForPage(
int $pageId,
int $currentTimestamp,
int $workspaceUid = 0,
int $languageUid = 0
): ?int;
Each of them runs one MIN query per monitored table and per temporal field
(starttime, endtime) and returns the smallest non-null result.
With the two default tables that is four queries.
find builds every query the same way:
removeon the restrictions. TYPO3'sAll () Start/Time Restriction Endwould hide exactly the future records the lookup needs.Time Restriction MINselected as a literal,(<field>) WHERE <field> > :. Records withnow 0are excluded by that comparison, so no separate!= 0clause exists.- The table's
deletedanddisabledcolumns, resolved from TCA, each compared to0. When no TCA is available the query simply runs without them. pid = :when the caller passed a page id.page Id - The workspace clause, for tables whose TCA sets
ctrl.(and for tables without TCA): for workspaceversioning WS 0it matchest3ver_, otherwisewsid = 0 OR t3ver_ wsid IS NULL t3ver_.wsid = : workspace <languagewhenever the language id isField> IN (: language, - 1) >= 0, with the column from TCActrl.(language Field sys_without TCA, no clause for a table that is not localized). Records for all languages (language_ uid -1) count for every language.
Request-level memoization
get — the site-wide lookup only — is memoized in
Netresearch\, a singleton keyed by
timestamp, workspace id and language id.
Repeated site-wide lookups within the same request and the same second are answered from
memory.
get and get are not memoized.
Indexes
ext_tables.sql adds two composite indexes per default table so the MIN
aggregation and the > : range scan can be served from an index:
pages:idx_andtemporalcache_ starttime (starttime, sys_ language_ uid) idx_temporalcache_ endtime (endtime, sys_ language_ uid) tt_: the same two indexescontent
temporalcache: checks that these indexes exist.
Tables registered by other extensions get no index from this extension.
What each scoping strategy does
A scoping strategy answers two separate questions, and the answers do not have to agree.
getNext Transition () - Used by
Dynamicto compute a cache lifetime.Timing Strategy getCache Tags To Flush () - Used by
Schedulerto flush caches from the scheduler task.Timing Strategy Dynamicnever calls it.Timing Strategy
| Strategy | get covers |
get returns |
|---|---|---|
global | Every monitored table, site-wide. The page id is ignored. | ['pages'] (ALL_) — the scheduler empties the whole page cache for it,
because no page cache entry carries that tag. |
per- | The pages table site-wide, plus the content tables restricted to the rendered
page. Falls back to the site-wide lookup when no page id is available. | ['page for a page, ['page for a content element. |
per- | Every monitored table, site-wide — the same lookup as global. | One page tag per page that sys_ reports for a
tt_ element; ['page for a record of another registered
table. |
Important
per- narrows the flush tags, not the cache lifetime.
Its get deliberately returns the site-wide transition, because a
content element can be embedded into arbitrary pages and a narrowed lifetime would risk
serving stale embedded content.
Combined with dynamic timing — which only ever reads the lifetime — per-
therefore behaves exactly like global.
Its precision takes effect with scheduler or hybrid timing.
Note
per- keeps menus correct by watching all page transitions site-wide, but it does
not see content embedded from another page through CONTENT/RECORDS cObjects.
That page's lifetime is not shortened when the embedded element transitions.
Refindex resolution
Per resolves the affected pages through
Netresearch\, which reads sys_.
It falls back to the element's own pid when scoping. is off, when the
lookup returns no page, or when it throws.
Pages are never resolved through the refindex — a page transition always yields the page's
own tag.
What each timing strategy does
| Strategy | get |
process |
|---|---|---|
dynamic | Seconds until the scoping strategy's next transition, capped at
advanced.. 60 if the transition is already in the past,
advanced. if there is none. | No-op. Expiry alone does the work. |
scheduler | null — the listener leaves TYPO3's lifetime alone. | Flushes every tag the scoping strategy returns from the pages cache. |
hybrid | Delegates to the strategy configured under timing.. | Delegates per record: timing. for a page,
timing. for anything else. |
Note
Hybrid always uses the pages rule; it cannot
tell during page generation which content elements a page contains.
With the default pages = dynamic the lifetime calculation therefore still queries
the content tables through the scoping strategy.
The scheduler task
Netresearch\ is what drives
process.
Each run reads the last-run timestamp from TYPO3's Registry
(namespace tx_, key scheduler_), asks
find for every transition since then, hands each one to the active
timing strategy, and writes the new timestamp back.
A transition that fails is logged and the run continues with the next one.
The interval is whatever frequency the task is given in the Scheduler module.
Workspace and language awareness
All three scoping strategies read the current context through the trait
Netresearch\:
$workspaceId = $context->getPropertyFromAspect('workspace', 'id', 0);
$languageId = $context->getPropertyFromAspect('language', 'id', 0);
Both values are passed down into every query, so a workspace preview and each language resolve their own next transition and therefore their own cache lifetime. One frontend request carries one workspace and one language, so the number of queries does not grow with the number of languages configured on the site.
Extensibility
Monitoring additional tables
Additional tables are registered with
Netresearch\.
The registry is a singleton and register is an instance method. Register from
ext_localconf.php, which runs before any transition lookup:
<?php
defined('TYPO3') or die();
\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(
\Netresearch\TemporalCache\Service\TemporalMonitorRegistry::class
)->registerTable(
'tx_news_domain_model_news',
['uid', 'pid', 'title', 'starttime', 'endtime', 'hidden', 'deleted', 'sys_language_uid']
);
A registration performed in the constructor of a service of your own does not work reliably: unless something the request instantiates references that service, Symfony removes the definition when it compiles the container, and the registration never runs. The failure is silent — the table is simply not monitored.
The second argument is optional. Omitting it applies the default field list, which is the one shown above.
Note
The registry has no field mapping: the table's temporal columns must literally be named
starttime and endtime.
register throws an Invalid when the field list omits
uid, starttime or endtime, when the table name is empty, or when it is
pages or tt_ — both are monitored by default and cannot be re-registered.
Temporal queries every table returned by
Temporal, so each registered table adds two MIN
queries per lookup.
get queries every registered table except pages,
which requires those tables to carry a pid.
Custom cache lifetime logic
Note
Temporal is final and cannot be extended.
Register your own listener instead.
namespace MyVendor\MyExtension\EventListener;
use TYPO3\CMS\Frontend\Event\ModifyCacheLifetimeForPageEvent;
final class CustomTemporalLogic
{
public function __invoke(ModifyCacheLifetimeForPageEvent $event): void
{
$customTransition = $this->getCustomTransition($event->getPageId());
if ($customTransition !== null) {
$event->setCacheLifetime(
\min($event->getCacheLifetime(), \max(0, $customTransition - \time()))
);
}
}
private function getCustomTransition(int $pageId): ?int
{
return null;
}
}
services:
MyVendor\MyExtension\EventListener\CustomTemporalLogic:
tags:
- name: event.listener
identifier: 'my-extension/custom-temporal-logic'
event: TYPO3\CMS\Frontend\Event\ModifyCacheLifetimeForPageEvent
after: 'temporal-cache/modify-cache-lifetime'
Known limitations
- Only the page cache is addressed
- The listener sets the page cache lifetime. Other caches keep whatever lifetime their own code assigns.
- Per-second granularity
- Transitions are Unix timestamps; nothing finer is possible.
- No cross-page dependency detection with
dynamictiming per-scoping does not shorten a page's lifetime for content embedded from elsewhere, andpage per-scoping only narrows flush tags.content - Scheduler timing flushes only what the scoping strategy names
- With
per-orpage per-scoping a page transition flushes only that page's own tag, so menus on other pages are not refreshed by the scheduler run.content globalscoping empties the whole page cache and does refresh them. - Additional tables get no indexes
ext_tables.sqlcoverspagesandtt_. A registered table needs its own index oncontent starttimeandendtime.
Next steps
- Performance considerations — configuration trade-offs
- Configuration — every option in detail
- Approach, limits and a core solution — where this approach sits relative to a core solution
- Source code
- Forge #14277 — the core issue