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
flushByTag()drops every entry carrying a given 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\CMS\Frontend\Event\ModifyCacheLifetimeForPageEvent while a page
cache entry is being written.
Netresearch\TemporalCache\EventListener\TemporalCacheLifetime is registered for exactly
that event class in Configuration/, 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 __invoke() body is wrapped in a try/catch (Throwable).
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
determineMaxLifetime() resolves the upper bound in this order:
cache_periodfrom the rendering instructions, if it is set and greater than zero (TypoScriptconfig.cache_period)advanced.default_max_lifetimefrom the extension configuration, if greater than zero86400
The listener also caps the value the timing strategy already capped, because
DynamicTimingStrategy limits its own result to advanced.default_max_lifetime
independently.
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 (
ScopingStrategyInterface) - Answers which records a transition lookup covers and which cache tags a transition
flushes.
Implementations:
GlobalScopingStrategy,PerPageScopingStrategy,PerContentScopingStrategy. - Timing strategy (
TimingStrategyInterface) - Answers when the invalidation happens — through a shortened cache lifetime, through a
background scheduler run, or a mix of the two.
Implementations:
DynamicTimingStrategy,SchedulerTimingStrategy,HybridTimingStrategy.
Both interfaces extend Netresearch\TemporalCache\Service\NamedStrategyInterface, which
declares the single method getName(): string.
How a strategy is activated
Every strategy is registered as a service carrying one of two tags:
nr_temporal_cache.scoping_strategynr_temporal_cache.timing_strategy
ScopingStrategyFactory and TimingStrategyFactory receive all services carrying
their family's tag through Symfony's !tagged_iterator:
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\TemporalCache\Service\SelectsNamedStrategy:
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.strategy respectively timing.strategy from the
extension configuration.
Three consequences follow from this code:
- The tags carry no
identifierattribute. A strategy is identified by itsgetName()return value alone. - When no name matches, the first tagged service wins.
GlobalScopingStrategyandDynamicTimingStrategycarrypriority: 100inConfiguration/so that they come first and hold that fallback.Services. yaml - When a family has no tagged service at all, the factory throws a
RuntimeException.
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 ScopingStrategyInterface or TimingStrategyInterface
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.strategy to rootline then activates it.
Note
Inside this extension, autoregistration excludes Service/Scoping/*Strategy.php and
Service/Timing/*Strategy.php, 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\TemporalCache\Domain\Repository\TemporalContentRepository
(contract: TemporalContentRepositoryInterface).
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.
findMinTransitionForTable() builds every query the same way:
removeAll()on the restrictions. TYPO3'sStartTimeRestriction/EndTimeRestrictionwould hide exactly the future records the lookup needs.MIN(<field>)selected as a literal,WHERE <field> > :now. Records with0are 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 = :pageIdwhen the caller passed a page id.- The workspace clause: for workspace
0it matches `t3ver_`.wsid = 0 OR t3ver_ wsid IS NULL``, otherwise ``t3ver_ wsid = : workspace sys_language_uid = :languagewhenever the language id is>= 0.
Request-level memoization
getNextTransition() — the site-wide lookup only — is memoized in
Netresearch\TemporalCache\Service\Cache\TransitionCache, 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.
getNextPageTransition() and getNextContentTransitionForPage() are not memoized.
Indexes
ext_ adds two composite indexes per default table so the MIN()
aggregation and the > :now range scan can be served from an index:
pages:idx_temporalcache_starttime (starttime, sys_language_uid)andidx_temporalcache_endtime (endtime, sys_language_uid)tt_content: the same two indexes
temporalcache:verify 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.
getNextTransition()- Used by
DynamicTimingStrategyto compute a cache lifetime. getCacheTagsToFlush()- Used by
SchedulerTimingStrategyto flush caches from the scheduler task.DynamicTimingStrategynever calls it.
| Strategy | getNextTransition() covers |
getCacheTagsToFlush() returns |
|---|---|---|
global | Every monitored table, site-wide. The page id is ignored. | ['pages'] — the tag every page cache entry carries. |
per-page | 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. | ['pageId_<uid>'] for a page, ['pageId_<pid>'] for a content element. |
per-content | Every monitored table, site-wide — the same lookup as global. | One pageId_<uid> tag per page that sys_refindex reports for the element. |
Important
per-content narrows the flush tags, not the cache lifetime.
Its getNextTransition() 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-content
therefore behaves exactly like global.
Its precision takes effect with scheduler or hybrid timing.
Note
per-page 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
PerContentScopingStrategy resolves the affected pages through
Netresearch\TemporalCache\Service\RefindexService, which reads sys_refindex.
It falls back to the element's own pid when scoping.use_refindex 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 | getCacheLifetime() |
processTransition() |
|---|---|---|
dynamic | Seconds until the scoping strategy's next transition, capped at
advanced.default_max_lifetime. 60 if the transition is already in the past,
advanced.default_max_lifetime 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.hybrid.pages. | Delegates per record: timing.hybrid.pages for a page,
timing.hybrid.content for anything else. |
Note
HybridTimingStrategy::getCacheLifetime() 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\TemporalCache\Task\TemporalCacheSchedulerTask is what drives
processTransition().
Each run reads the last-run timestamp from TYPO3's Registry
(namespace tx_temporalcache, key scheduler_last_run), asks
findTransitionsInRange() 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\TemporalCache\Service\Scoping\ResolvesContextAspects:
$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\TemporalCache\Service\TemporalMonitorRegistry.
The registry is an autowired singleton and registerTable() is an instance method, so
obtain it through constructor injection — there is no static registration API:
namespace MyVendor\MyExtension\Service;
use Netresearch\TemporalCache\Service\TemporalMonitorRegistry;
final class NewsTemporalRegistration
{
public function __construct(
private readonly TemporalMonitorRegistry $monitorRegistry
) {
$this->monitorRegistry->registerTable(
'tx_news_domain_model_news',
['uid', 'pid', 'title', 'starttime', 'endtime', 'hidden', 'deleted', 'sys_language_uid']
);
}
}
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.
registerTable() throws an InvalidArgumentException when the field list omits
uid, starttime or endtime, when the table name is empty, or when it is
pages or tt_content — both are monitored by default and cannot be re-registered.
TemporalContentRepository queries every table returned by
TemporalMonitorRegistry::getAllTables(), so each registered table adds two MIN()
queries per lookup.
getNextContentTransitionForPage() queries every registered table except pages,
which requires those tables to carry a pid.
Custom cache lifetime logic
Note
TemporalCacheLifetime 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-pagescoping does not shorten a page's lifetime for content embedded from elsewhere, andper-contentscoping only narrows flush tags.- Scheduler timing flushes only what the scoping strategy names
- With
per-pageorper-contentscoping a page transition flushes only that page's own tag, so menus on other pages are not refreshed by the scheduler run.globalscoping flushes thepagestag and does refresh them. - Additional tables get no indexes
ext_coverstables. sql pagesandtt_content. A registered table needs its own index onstarttimeandendtime.
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