Skip to content
Open
120 changes: 120 additions & 0 deletions .claude/rules/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Architecture

## Overview

Symfony bundle for Pimcore that adds HTTP cache tagging and invalidation. Bridges Pimcore's element lifecycle to FOSHttpCacheBundle via adapters, decorators, and events.

- Service definitions live in `config/services.php` (PHP-based DI, not YAML)
- Core interfaces are single-method: `CacheInvalidator::invalidate(CacheTags)` and `ResponseTagger::tag(CacheTags)`

## Decorator Chains

Both `ResponseTagger` and `CacheInvalidator` are built as decoration chains. Lower priority = outer decorator (executes first).

**ResponseTagger chain:**
```
CacheTagCollectionResponseTagger (priority +1, removed if profiler absent)
→ OnlyWhenActiveResponseTagger (priority -100, checks CacheActivator)
→ RemoveDisabledTagsResponseTagger (priority -99, filters via CacheTagChecker)
→ ResponseTaggerAdapter (base, bridges to FOSHttpCache)
```

**CacheInvalidator chain:**
```
OnlyWhenActiveCacheInvalidator (priority -100, checks CacheActivator)
→ RemoveDisabledTagsCacheInvalidator (priority -99, filters via CacheTagChecker)
→ CacheInvalidatorAdapter (base, bridges to FOSHttpCache CacheManager)
```

Each decorator has a single responsibility: activation check, tag filtering, or profiler collection.

## Adapter Layer

`ResponseTaggerAdapter` and `CacheInvalidatorAdapter` sit at the bottom of the decoration chains. They convert the bundle's `CacheTags` value object to string arrays and delegate to FOSHttpCacheBundle (`FosResponseTagger::addTags()` and `CacheManager::invalidateTags()`). This isolates the FOSHttpCache dependency to these two classes.

## Element Lifecycle Flows

**Tagging (element load → response tagged):**
1. Pimcore fires `POST_LOAD` event (asset/document/object)
2. `TagElementListener` receives it, creates `ElementTaggingEvent`, dispatches it
3. Application listeners can add tags or set `cancel = true`
4. If not cancelled, calls `ResponseTagger::tag()` → decoration chain → FOSHttpCache

**Invalidation (element save/delete → cache purged):**
1. Pimcore fires `POST_UPDATE` or `PRE_DELETE` event
2. `InvalidateElementListener` receives it (skips `saveVersionOnly`/`autoSave`)
3. Creates `ElementInvalidationEvent`, dispatches it
4. Application listeners can add related tags or cancel
5. If not cancelled, calls `CacheInvalidator::invalidate()` → decoration chain → FOSHttpCache

Listeners are registered dynamically — only for enabled element types (configured in YAML).

## CacheTagChecker Composition

Checkers decide if a tag is enabled based on configuration. They compose via constructor injection, not decoration:

```
ElementCacheTagChecker
├── inner: StaticCacheTagChecker (handles custom/empty types via cache_types config)
├── asset: AssetCacheTagChecker (loads asset, checks type against config)
├── document: DocumentCacheTagChecker (loads document, checks type against config)
└── object: ObjectCacheTagChecker (loads object, checks type + class against config)
```

`ElementCacheTagChecker` routes element tags to the right specific checker via `match` on `ElementType`. Non-element tags fall through to `StaticCacheTagChecker`.

Each element checker: loads the element by ID from `ElementRepository` → checks if the element's type (and class for objects) is enabled in config → defaults to `true` if not explicitly configured.

## CacheType Hierarchy

`CacheType` interface defines how a tag string is formatted:

| Type | `applyTo("42")` | `toString()` | `isEmpty()` | When used |
|------|-----------------|--------------|-------------|-----------|
| `ElementCacheType(Asset)` | `"a42"` | `"a"` | `false` | Pimcore elements |
| `ElementCacheType(Document)` | `"d42"` | `"d"` | `false` | Pimcore elements |
| `ElementCacheType(Object)` | `"o42"` | `"o"` | `false` | Pimcore elements |
| `CustomCacheType("product")` | `"product-42"` | `"product"` | `false` | User-defined types |
| `EmptyCacheType` | `"42"` | `""` | `true` | Raw string tags |

Note: Element types use direct concatenation (`a42`), custom types use dash separator (`product-42`).

`CacheTypeFactory` creates the right type: `createFromElement()` → `ElementCacheType`, `createFromString("asset")` → `ElementCacheType`, `createFromString("product")` → `CustomCacheType`, `createEmpty()` → `EmptyCacheType`.

Reserved prefixes `a`, `d`, `o` cannot be used for custom types (`ElementCacheType::isReserved()`).

## Immutable Value Objects

- **`CacheTag`**: private constructor, created via `fromString()` / `fromElement()`. Holds `tag` string + `CacheType`. `toString()` delegates to `$type->applyTo($tag)`.
- **`CacheTags`**: immutable collection indexed by tag string. `with()` returns new instance with merged tags. `withoutDisabled($checker)` returns new instance with filtered tags. Implements `IteratorAggregate`.

## Configuration Flow

```
YAML config
→ Configuration tree builder (normalizes defaults: folder=false, email=false, hardlink=false)
→ NeustaPimcoreHttpCacheExtension::loadInternal()
→ Injects cache_types into StaticCacheTagChecker
→ Injects element config into AssetCacheTagChecker / DocumentCacheTagChecker / ObjectCacheTagChecker
→ Dynamically adds event listener tags to TagElementListener / InvalidateElementListener
→ Stores full config as container parameter for DataCollector
```

Key: if an element type is disabled in config, its event listeners are never registered — zero overhead.

## Compiler Pass

`DisableDataCollectorPass`: if the Symfony profiler service is absent, removes `CacheTagCollectionResponseTagger` and `DataCollector`. This avoids the memory overhead of collecting tags in production where the profiler isn't available.

## Design Patterns Summary

| Pattern | Where | Why |
|---------|-------|-----|
| Decorator | ResponseTagger + CacheInvalidator chains | Composable, ordered processing with single responsibility per layer |
| Adapter | ResponseTaggerAdapter, CacheInvalidatorAdapter | Isolates FOSHttpCache dependency |
| Strategy | CacheType hierarchy | Different tag formatting per type |
| Chain of Responsibility | CacheTagChecker composition | Each checker handles its own tag types |
| Event-Driven | ElementTaggingEvent, ElementInvalidationEvent | Loose coupling, extensibility for application code |
| Immutable Value Object | CacheTags, CacheTag | Prevents accidental mutation, thread safety |
| Named Constructor | CacheTag::fromString(), CacheTags::fromElement() | Expressive, validated object creation |
| Named Exception Factory | InvalidArgumentException::becauseCacheTagIsEmpty() | Readable, centralized error messages |
121 changes: 121 additions & 0 deletions .claude/rules/bundle-usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Bundle Usage & Concepts

## What This Bundle Does

Adds automatic HTTP cache invalidation to Pimcore via cache tags. When a Pimcore element (document, asset, data object) is loaded during a request, the response gets tagged. When that element changes, the cache is invalidated. Works with reverse proxies (Varnish, Fastly) via FOSHttpCacheBundle.

## Automatic Behavior

**Tagging** — when an element is loaded during a request:
1. Pimcore fires a `POST_LOAD` event
2. Bundle dispatches `ElementTaggingEvent`
3. Response is tagged with `a{id}` (asset), `d{id}` (document), or `o{id}` (object)
4. Tags appear in the `X-Cache-Tags` header

**Invalidation** — when an element is saved or deleted:
1. Pimcore fires `POST_UPDATE` or `PRE_DELETE`
2. Bundle dispatches `ElementInvalidationEvent`
3. Cache tags are invalidated via FOSHttpCacheBundle
4. Reverse proxy purges matching responses
5. Skipped for `saveVersionOnly` and `autoSave` operations

## Configuration

```yaml
neusta_pimcore_http_cache:
elements:
assets:
enabled: true
types:
folder: false # disabled by default
documents:
enabled: true
types:
email: false # disabled by default
folder: false # disabled by default
hardlink: false # disabled by default
objects:
enabled: true
types:
folder: false # disabled by default
classes:
MyClass: false # disable specific data object class
cache_types:
my_custom_type: true # must be defined here before use
```

## Public API

Services available via autowiring:

- **`CacheActivator`** — toggle caching on/off programmatically
- **`CacheInvalidator`** — manually invalidate cache tags
- **`ResponseTagger`** — manually tag the current response

Value objects:

- **`CacheTags`** — immutable collection; create via `fromString()`, `fromStrings()`, `fromElement()`, `fromElements()`; combine with `with()`
- **`CacheTag`** — single tag; create via `fromString()`, `fromElement()`
- **`CacheTypeFactory`** — creates `ElementCacheType`, `CustomCacheType`, or `EmptyCacheType`

## Events

Both events share the same API — `element`, `elementType`, `addTag()`, `addTags()`, `cacheTags()`, and `cancel`:

**`ElementTaggingEvent`** — fired before tagging a response:
```php
#[AsEventListener]
final class AddRelatedTags
{
public function __invoke(ElementTaggingEvent $event): void
{
// Add related element tags
$event->addTags(CacheTags::fromElements($event->element->getRelatedProducts()));

// Or cancel tagging entirely
$event->cancel = true;
}
}
```

**`ElementInvalidationEvent`** — fired before invalidating cache:
```php
#[AsEventListener]
final class InvalidateRelated
{
public function __invoke(ElementInvalidationEvent $event): void
{
// Invalidate related content too
$event->addTag(CacheTag::fromElement($relatedElement));
}
}
```

## Custom Cache Types

For grouping tags beyond the built-in element types:

1. Register in config: `cache_types: { product_category: true }`
2. Use in code:
```php
$tag = CacheTag::fromString('42', new CustomCacheType('product_category'));
// Produces tag: "product_category-42"
```
3. Invalidate by type:
```php
$cacheInvalidator->invalidate(
CacheTags::fromStrings(['42'], type: new CustomCacheType('product_category'))
);
```

Reserved prefixes `a`, `d`, `o` cannot be used for custom types.

## Three Ways to Disable Caching

1. **Configuration** — permanently disable types/classes in YAML
2. **Events** — set `$event->cancel = true` conditionally
3. **`CacheActivator`** — `deactivateCaching()` disables everything at runtime (useful in tests)

## Profiler

Shows in the Symfony profiler toolbar when the profiler is enabled. Displays all cache tags applied to the response and the current bundle configuration.
107 changes: 107 additions & 0 deletions .claude/rules/code-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Code Style

## General

- Every PHP file starts with `<?php declare(strict_types=1);` (no blank line between)
- Follow Symfony coding standards (`@Symfony` + `@Symfony:risky`)
- Indentation: 4 spaces for PHP, 2 spaces for JSON/YAML, tabs for .neon files
- Line endings: LF only
- Single quotes for strings; double quotes only when interpolation/escape sequences are needed
- Always run `composer cs:fix` before committing

## Classes

- Classes are `final` by default
- Interfaces have clean names — no `Interface` suffix (e.g., `CacheType`, not `CacheTypeInterface`)
- One blank line between methods, no blank lines between properties

## Constructors & Properties

- Always use **constructor property promotion** with `readonly`:
```php
public function __construct(
private readonly ResponseTagger $inner,
private readonly CacheActivator $cacheActivator,
) {
}
```
- Private constructor + public static factory methods for value objects:
```php
private function __construct(
public readonly string $tag,
public readonly CacheType $type,
) {
}

public static function fromString(string $tag, ?CacheType $type = null): self
```

## Type Hints & Return Types

- Return types always present, including `void`
- Nullable with `?` prefix: `?Asset`
- Union types where needed: `CacheTag|self`
- PHPDoc only when PHP type system is insufficient (generics, array shapes):
```php
/** @param array<string, bool> $types */
/** @return array<array{tag: string, type: string}> */
```
- Omit doc blocks when native type hints are sufficient

## Trailing Commas

- Always use trailing commas in multiline structures — constructor params, arrays, function calls, match arms

## Global Functions

- Fully qualified for native functions: `\count()`, `\sprintf()`, `\assert()`, `\in_array()`

## Null Handling

- Null coalescing `??` preferred: `$type ?? CacheTypeFactory::createEmpty()`
- Null coalescing assignment `??=` for lazy init: `$prefixes ??= array_map(...)`
- Early return with assignment for null checks:
```php
if (!$id = $element->getId()) {
throw InvalidArgumentException::becauseElementHasNoId();
}
```

## Exceptions

- Named static factory methods on exception classes:
```php
throw InvalidArgumentException::becauseCacheTagIsEmpty();
throw InvalidArgumentException::becauseCacheTypeIsReserved($type);
```
- Multi-line `\sprintf()` for longer messages is fine
- Custom exception interface (`PimcoreHttpCacheException`) as marker

## Modern PHP Features

- **Match expressions** over switch:
```php
return match ($tag->type->type) {
ElementType::Asset => $this->asset->isEnabled($tag),
ElementType::Document => $this->document->isEnabled($tag),
ElementType::Object => $this->object->isEnabled($tag),
};
```
- **Arrow functions** for short closures: `fn ($tag) => CacheTag::fromString($tag)`
- **First-class callable syntax**: `array_map(CacheTag::fromElement(...), $elements)`
- **Variadic parameters** with union types: `CacheTag|self ...$tags`
- **Backed enums** with methods:
```php
enum ElementType: string
{
case Asset = 'asset';
case Document = 'document';
case Object = 'object';
}
```
- **Spread operator** for arrays: `[...$newTags, ...$tag->tags]`
- **`@no-named-arguments`** annotation on variadic constructors

## Concatenation

- Spaces around `.` operator: `'foo' . 'bar'` not `'foo'.'bar'`
27 changes: 27 additions & 0 deletions .claude/rules/roles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Roles

Claude Code operates in two distinct roles in this project. Never mix them.

## Writer Role (default)

You ARE the author. You write code, fix bugs, implement features.
- Follow all rules in `code-style.md`, `testing.md`, `architecture.md`
- Use TDD: write failing test first, then implementation
- Run `composer cs:fix` and `composer phpstan` before finishing
- You own the code — make decisions, implement them

## Reviewer Role (via `/review`)

You are NOT the author. You review someone else's changes (or your own from a prior session).
- **Do not fix issues yourself** — report them with file:line, severity, and suggested fix
- **Do not assume intent** — ask if a change is unclear
- **Be specific** — every finding needs a concrete location and reason
- **Check all 8 categories**: dead code, architecture, documentation, tests, security, performance, BC breaks, static analysis
- If you wrote the code being reviewed, be extra critical — you have blind spots on your own work

## Switching Roles

- Default is Writer
- Use `/review` to switch to Reviewer mode
- After review is complete, you return to Writer if asked to fix findings
- Never review and fix in the same pass — separate the roles
6 changes: 6 additions & 0 deletions .claude/rules/static-analysis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Static Analysis

- PHPStan level 8 — all code in `src/` must pass
- Run with `composer phpstan`
- PHPStan extensions in use: phpstan-phpunit, phpstan-symfony, phpstan-prophecy
- Do not lower the PHPStan level or add ignoreErrors for new code — fix the issues instead
Loading
Loading