Skip to content
Draft
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
cd296b3
Invalidate dependent elements
jan888adams Aug 7, 2025
70c0a27
Refactor test factories
jan888adams Aug 12, 2025
c0ede1b
Add testcases for asset & document invalidation
jan888adams Aug 13, 2025
b3161a4
Only invalidate dependencies from objects
jan888adams Aug 20, 2025
2656151
Test invalidation for dependent documents
jan888adams Aug 20, 2025
365cd1a
Fix review findings: final class, and negative dependency traversal t…
Mar 2, 2026
0968867
Revert ElementRepository to non-final class (required for Prophecy mo…
Mar 2, 2026
dbbf303
Remove asset case from dependency invalidation — assets cannot refere…
Mar 2, 2026
7a02f80
Restore asset case in dependency invalidation
Mar 2, 2026
3ffcd0f
Make dependency traversal configurable, disabled by default
Mar 2, 2026
ba1c99d
Fix review findings: int cast, test naming, wrong import, missing tests
Mar 2, 2026
b3b418e
Document invalidate_dependencies configuration
Mar 2, 2026
c35ca2f
Fix review findings: extract shared traversal method, clarify docs
Mar 2, 2026
f25af37
Honor cancellation before dependency traversal
Mar 2, 2026
36b225c
Fix type mismatch in dependency traversal: use tryFromElement()
Mar 2, 2026
e022faa
Run save() inside arrange() closure
Mar 2, 2026
d2aa5c1
Fix assertion: check additional tag o12, not primary tag o5
Mar 2, 2026
c0591a4
Fix mismatched hardlink tag assertion: check d12, not d29
Mar 2, 2026
dacd82e
Move configKey() from InvalidateElementListener to ElementType enum
Mar 3, 2026
2438a32
Extract shouldSkipInvalidation() into a private method
Mar 3, 2026
f5f0256
Introduce ElementsConfig as a compile-time config value object
Mar 3, 2026
dc7ac15
Address review findings: document traversal depth and cover missing c…
Mar 3, 2026
b434ec9
Extract DependencyInvalidator class from InvalidateElementListener
Mar 3, 2026
00f70ab
Address review findings: fix callable PHPDoc and add missing tests
Mar 3, 2026
778c512
Form a domain around 'dependent element'
Mar 3, 2026
4e6b8a2
Address review findings: final ElementsConfig and missing source-type…
Mar 3, 2026
eab4a09
Rename DependentElementInvalidator to DependentElementFinder
Mar 3, 2026
15675d1
Fix docs: update config key from invalidate_dependencies to invalidat…
Mar 3, 2026
d2e0b39
Honor element subtype and class config in DependentElementFinder
Mar 3, 2026
53897b2
Add fine-grained subtype/class config for dependent element invalidation
Mar 3, 2026
b6c11fe
Address review findings: add fine-grained config docs and two-layer f…
Mar 3, 2026
f387e7e
Move array @param annotations inline on promoted constructor params
Mar 4, 2026
26a1284
Refactor: move element checks into ElementsConfig, clean up naming
Mar 4, 2026
02e6e08
Add fine-grained subtype/class config example to configuration overview
Mar 4, 2026
38a85c3
Add missing tests and fix pre-existing factory/naming issues
Mar 4, 2026
b30e6e9
Address review findings: clean up services placeholder and add positi…
Mar 4, 2026
79cdf3b
Apply PHP CS Fixer style fixes
Mar 4, 2026
62d8756
Fix phpstan errors, test assertions and pre-filter optimization
Mar 4, 2026
09b29b1
Fix pre-existing test bugs exposed after removing merge commit
Mar 4, 2026
3d7e9aa
Remove unused source element getType() mocks from DependentElementFin…
Mar 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
use Neusta\Pimcore\HttpCacheBundle\Cache\ResponseTagger\RemoveDisabledTagsResponseTagger;
use Neusta\Pimcore\HttpCacheBundle\CacheActivator;
use Neusta\Pimcore\HttpCacheBundle\DataCollector;
use Neusta\Pimcore\HttpCacheBundle\Element\DependencyInvalidator;
use Neusta\Pimcore\HttpCacheBundle\Element\ElementRepository;
use Neusta\Pimcore\HttpCacheBundle\Element\ElementsConfig;
use Neusta\Pimcore\HttpCacheBundle\Element\InvalidateElementListener;
use Neusta\Pimcore\HttpCacheBundle\Element\TagElementListener;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
Expand Down Expand Up @@ -73,25 +75,34 @@

$services->set('.neusta_pimcore_http_cache.element.repository', ElementRepository::class);

$services->set('neusta_pimcore_http_cache.elements_config', ElementsConfig::class)
->factory([ElementsConfig::class, 'fromArray'])
->arg('$config', []);

$services->set('neusta_pimcore_http_cache.cache_tag_checker.element.asset', AssetCacheTagChecker::class)
->arg('$repository', service('.neusta_pimcore_http_cache.element.repository'))
->arg('$config', ['enabled' => false, 'types' => []]);
->arg('$config', service('neusta_pimcore_http_cache.elements_config'));

$services->set('neusta_pimcore_http_cache.cache_tag_checker.element.document', DocumentCacheTagChecker::class)
->arg('$repository', service('.neusta_pimcore_http_cache.element.repository'))
->arg('$config', ['enabled' => false, 'types' => []]);
->arg('$config', service('neusta_pimcore_http_cache.elements_config'));

$services->set('neusta_pimcore_http_cache.cache_tag_checker.element.object', ObjectCacheTagChecker::class)
->arg('$repository', service('.neusta_pimcore_http_cache.element.repository'))
->arg('$config', ['enabled' => false, 'types' => [], 'classes' => []]);
->arg('$config', service('neusta_pimcore_http_cache.elements_config'));

$services->set('neusta_pimcore_http_cache.element.tag_listener', TagElementListener::class)
->arg('$responseTagger', service('neusta_pimcore_http_cache.response_tagger'))
->arg('$dispatcher', service('event_dispatcher'));

$services->set('neusta_pimcore_http_cache.element.dependency_invalidator', DependencyInvalidator::class)
->arg('$elementRepository', service('.neusta_pimcore_http_cache.element.repository'))
->arg('$config', service('neusta_pimcore_http_cache.elements_config'));

$services->set('neusta_pimcore_http_cache.element.invalidate_listener', InvalidateElementListener::class)
->arg('$cacheInvalidator', service('neusta_pimcore_http_cache.cache_invalidator'))
->arg('$dispatcher', service('event_dispatcher'));
->arg('$dispatcher', service('event_dispatcher'))
->arg('$dependencyInvalidator', service('neusta_pimcore_http_cache.element.dependency_invalidator'));

$services->set('neusta_pimcore_http_cache.data_collector', DataCollector::class)
->arg('$cacheTagCollector', service('.neusta_pimcore_http_cache.collect_tags_response_tagger'))
Expand Down
40 changes: 32 additions & 8 deletions doc/2-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,52 @@ neusta_pimcore_http_cache:
types:
archive: false
unknown: false

# Unless you disable assets completely

# Invalidate dependent elements when an asset changes (disabled by default).
# Note: a dependent element type must also be enabled above for invalidation to take effect.
invalidate_dependencies:
enabled: true
types:
objects: true
documents: true

# Or disable assets completely (mutually exclusive with the options above)
enabled: false

documents:
# By default, every type except "email", "folder" and "hardlink" is enabled
types:
link: false

# Unless you disable documents completely

# Invalidate dependent elements when a document changes (disabled by default).
# Note: a dependent element type must also be enabled above for invalidation to take effect.
invalidate_dependencies:
enabled: true
types:
objects: true

# Or disable documents completely (mutually exclusive with the options above)
enabled: false

objects:
# By default, every type except "folder" is enabled
types:
variant: false

# By default, every data object class is enabled
classes:
MyDataObjectClass: false

# Unless you disable data objects completely
# Invalidate dependent elements when an object changes (disabled by default).
# Note: a dependent element type must also be enabled above for invalidation to take effect.
invalidate_dependencies:
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think element types alone might not be sufficient. We should probably consider subtypes as well.

enabled: true
types:
objects: true
documents: true
assets: true

# Or disable data objects completely (mutually exclusive with the options above)
enabled: false

# Enable/disable cache handling for custom cache types
Expand Down
64 changes: 64 additions & 0 deletions doc/3-pimcore-elements.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,67 @@ neusta_pimcore_http_cache:
classes:
MyDataObjectClass: false
```

## Dependent Element Invalidation

When a Pimcore element is updated or deleted, other elements that reference it may also serve stale content.
For example, a document that embeds a data object will be outdated as soon as that object changes.

By default, the bundle only invalidates the cache tag of the element that was directly changed.
Dependent element invalidation — traversing Pimcore's dependency graph to also purge referencing elements — is **disabled by default** and must be opted in via configuration.

The dependency graph is one level deep: only elements that directly reference the changed element are invalidated, not transitive dependencies.

> **Note:** For a dependent element type to actually be invalidated, it must also be enabled in the main `elements` configuration. For example, setting `objects.invalidate_dependencies.types.documents: true` has no effect if `documents` is disabled — the cache tag will be silently dropped.

### Enable dependent invalidation for objects

The most common use case is invalidating documents and other objects that reference a changed data object.
The listed dependent types (`documents`, `objects`) must also be enabled in the `elements` configuration:

```yaml
neusta_pimcore_http_cache:
elements:
objects:
invalidate_dependencies:
enabled: true
types:
documents: true # invalidate documents that reference the object
objects: true # invalidate objects that reference the object
assets: false # leave assets out (default)
documents: true # must be enabled for document invalidation to take effect
```

### Enable dependent invalidation for assets

If an asset (e.g. an image) is referenced by objects or documents, those can be invalidated when the asset changes.
The listed dependent types must also be enabled in the `elements` configuration:

```yaml
neusta_pimcore_http_cache:
elements:
assets:
invalidate_dependencies:
enabled: true
types:
objects: true # invalidate objects that reference the asset
documents: true # invalidate documents that reference the asset
objects: true # must be enabled for object invalidation to take effect
documents: true # must be enabled for document invalidation to take effect
```

### Enable dependent invalidation for documents

If a document is referenced by other elements (e.g. an object with a document relation field), those elements can be invalidated when the document changes.
The listed dependent types must also be enabled in the `elements` configuration:

```yaml
neusta_pimcore_http_cache:
elements:
documents:
invalidate_dependencies:
enabled: true
types:
objects: true # invalidate objects that reference the document
objects: true # must be enabled for object invalidation to take effect
```
10 changes: 4 additions & 6 deletions src/Cache/CacheTagChecker/Element/AssetCacheTagChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,13 @@
use Neusta\Pimcore\HttpCacheBundle\Cache\CacheType\ElementCacheType;
use Neusta\Pimcore\HttpCacheBundle\Element\ElementRepository;
use Neusta\Pimcore\HttpCacheBundle\Element\ElementType;
use Neusta\Pimcore\HttpCacheBundle\Element\ElementsConfig;

final class AssetCacheTagChecker implements CacheTagChecker
{
/**
* @param array{enabled: bool, types: array<string, bool>} $config
*/
public function __construct(
private readonly ElementRepository $repository,
private readonly array $config,
private readonly ElementsConfig $config,
) {
}

Expand All @@ -24,14 +22,14 @@ public function isEnabled(CacheTag $tag): bool
\assert($tag->type instanceof ElementCacheType, \sprintf('Cache type must be an instance of %s', ElementCacheType::class));
\assert(ElementType::Asset === $tag->type->type, \sprintf('Cache type must be "%s"', ElementType::Asset->value));

if (!$this->config['enabled']) {
if (!$this->config->isEnabled(ElementType::Asset)) {
return false;
}

if (!$asset = $this->repository->findAsset((int) $tag->tag)) {
return false;
}

return $this->config['types'][$asset->getType()] ?? true;
return $this->config->isTypeEnabled(ElementType::Asset, $asset->getType());
}
}
10 changes: 4 additions & 6 deletions src/Cache/CacheTagChecker/Element/DocumentCacheTagChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,13 @@
use Neusta\Pimcore\HttpCacheBundle\Cache\CacheType\ElementCacheType;
use Neusta\Pimcore\HttpCacheBundle\Element\ElementRepository;
use Neusta\Pimcore\HttpCacheBundle\Element\ElementType;
use Neusta\Pimcore\HttpCacheBundle\Element\ElementsConfig;

final class DocumentCacheTagChecker implements CacheTagChecker
{
/**
* @param array{enabled: bool, types: array<string, bool>} $config
*/
public function __construct(
private readonly ElementRepository $repository,
private readonly array $config,
private readonly ElementsConfig $config,
) {
}

Expand All @@ -24,14 +22,14 @@ public function isEnabled(CacheTag $tag): bool
\assert($tag->type instanceof ElementCacheType, \sprintf('Cache type must be an instance of %s', ElementCacheType::class));
\assert(ElementType::Document === $tag->type->type, \sprintf('Cache type must be "%s"', ElementType::Document->value));

if (!$this->config['enabled']) {
if (!$this->config->isEnabled(ElementType::Document)) {
return false;
}

if (!$document = $this->repository->findDocument((int) $tag->tag)) {
return false;
}

return $this->config['types'][$document->getType()] ?? true;
return $this->config->isTypeEnabled(ElementType::Document, $document->getType());
}
}
12 changes: 5 additions & 7 deletions src/Cache/CacheTagChecker/Element/ObjectCacheTagChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,14 @@
use Neusta\Pimcore\HttpCacheBundle\Cache\CacheType\ElementCacheType;
use Neusta\Pimcore\HttpCacheBundle\Element\ElementRepository;
use Neusta\Pimcore\HttpCacheBundle\Element\ElementType;
use Neusta\Pimcore\HttpCacheBundle\Element\ElementsConfig;
use Pimcore\Model\DataObject\Concrete;

final class ObjectCacheTagChecker implements CacheTagChecker
{
/**
* @param array{enabled: bool, types: array<string, bool>, classes: array<string, bool>} $config
*/
public function __construct(
private readonly ElementRepository $repository,
private readonly array $config,
private readonly ElementsConfig $config,
) {
}

Expand All @@ -25,22 +23,22 @@ public function isEnabled(CacheTag $tag): bool
\assert($tag->type instanceof ElementCacheType, \sprintf('Cache type must be an instance of %s', ElementCacheType::class));
\assert(ElementType::Object === $tag->type->type, \sprintf('Cache type must be "%s"', ElementType::Object->value));

if (!$this->config['enabled']) {
if (!$this->config->isEnabled(ElementType::Object)) {
return false;
}

if (!$object = $this->repository->findObject((int) $tag->tag)) {
return false;
}

if (!($this->config['types'][$object->getType()] ?? true)) {
if (!$this->config->isTypeEnabled(ElementType::Object, $object->getType())) {
return false;
}

if (!$object instanceof Concrete) {
return true;
}

return $this->config['classes'][$object->getClassName()] ?? true;
return $this->config->isClassEnabled($object->getClassName());
}
}
48 changes: 48 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ public function getConfigTreeBuilder(): TreeBuilder
->defaultValue(['folder' => false])
->booleanPrototype()->end()
->end()
->arrayNode('invalidate_dependencies')
->info('Enable/disable invalidation of dependent elements when an asset is updated or deleted.')
->canBeEnabled()
->addDefaultsIfNotSet()
->children()
->arrayNode('types')
->info('Enable/disable invalidation of dependent element types.')
->addDefaultsIfNotSet()
->children()
->booleanNode('assets')->defaultFalse()->end()
->booleanNode('documents')->defaultFalse()->end()
->booleanNode('objects')->defaultFalse()->end()
->end()
->end()
->end()
->end()
->end()
->end()
->arrayNode('documents')
Expand All @@ -54,6 +70,22 @@ public function getConfigTreeBuilder(): TreeBuilder
->defaultValue(['email' => false, 'folder' => false, 'hardlink' => false])
->booleanPrototype()->end()
->end()
->arrayNode('invalidate_dependencies')
->info('Enable/disable invalidation of dependent elements when a document is updated or deleted.')
->canBeEnabled()
->addDefaultsIfNotSet()
->children()
->arrayNode('types')
->info('Enable/disable invalidation of dependent element types.')
->addDefaultsIfNotSet()
->children()
->booleanNode('assets')->defaultFalse()->end()
->booleanNode('documents')->defaultFalse()->end()
->booleanNode('objects')->defaultFalse()->end()
->end()
->end()
->end()
->end()
->end()
->end()
->arrayNode('objects')
Expand All @@ -79,6 +111,22 @@ public function getConfigTreeBuilder(): TreeBuilder
->defaultValue([])
->booleanPrototype()->end()
->end()
->arrayNode('invalidate_dependencies')
->info('Enable/disable invalidation of dependent elements when an object is updated or deleted.')
->canBeEnabled()
->addDefaultsIfNotSet()
->children()
->arrayNode('types')
->info('Enable/disable invalidation of dependent element types.')
->addDefaultsIfNotSet()
->children()
->booleanNode('assets')->defaultFalse()->end()
->booleanNode('documents')->defaultFalse()->end()
->booleanNode('objects')->defaultFalse()->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
Expand Down
12 changes: 3 additions & 9 deletions src/DependencyInjection/NeustaPimcoreHttpCacheExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ private function registerElements(ContainerBuilder $container, array $config): v
$tagListener = $container->getDefinition('neusta_pimcore_http_cache.element.tag_listener');
$invalidateListener = $container->getDefinition('neusta_pimcore_http_cache.element.invalidate_listener');

if ($config['assets']['enabled']) {
$container->getDefinition('neusta_pimcore_http_cache.cache_tag_checker.element.asset')
->setArgument('$config', $config['assets']);
$container->getDefinition('neusta_pimcore_http_cache.elements_config')
->setArgument('$config', $config);

if ($config['assets']['enabled']) {
$tagListener
->addTag('kernel.event_listener', ['event' => AssetEvents::POST_LOAD]);

Expand All @@ -48,9 +48,6 @@ private function registerElements(ContainerBuilder $container, array $config): v
}

if ($config['documents']['enabled']) {
$container->getDefinition('neusta_pimcore_http_cache.cache_tag_checker.element.document')
->setArgument('$config', $config['documents']);

$tagListener
->addTag('kernel.event_listener', ['event' => DocumentEvents::POST_LOAD]);

Expand All @@ -60,9 +57,6 @@ private function registerElements(ContainerBuilder $container, array $config): v
}

if ($config['objects']['enabled']) {
$container->getDefinition('neusta_pimcore_http_cache.cache_tag_checker.element.object')
->setArgument('$config', $config['objects']);

$tagListener
->addTag('kernel.event_listener', ['event' => DataObjectEvents::POST_LOAD]);

Expand Down
Loading