Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
fd7cd4a
add optional dependency on opis/closure
Brajk19 Oct 26, 2025
2d2d7df
allow using closures in PurgeOn::if
Brajk19 Oct 26, 2025
52d9bbb
add tests
Brajk19 Oct 26, 2025
d889400
lint
Brajk19 Oct 26, 2025
9cbf895
update phpdoc
Brajk19 Oct 26, 2025
507b536
throw exception if using closures when target is association
Brajk19 Oct 26, 2025
7f51189
fix test
Brajk19 Oct 26, 2025
7902ebf
run tests with php 8.5
Brajk19 Oct 29, 2025
32d0fef
Merge branch '2.x' into closure-if
Brajk19 Mar 14, 2026
e70797b
add expectations for mock
Brajk19 Mar 14, 2026
e2381af
lint
Brajk19 Mar 14, 2026
ce4f53e
stan
Brajk19 Mar 14, 2026
be3b0d5
fix test
Brajk19 Mar 14, 2026
aacb9e2
cleanup
Brajk19 Mar 22, 2026
60314d0
debug command
Brajk19 Apr 25, 2026
8da47f0
fix tests
Brajk19 Apr 25, 2026
f80da12
Merge branch '2.x' into closure-if
Brajk19 Jun 4, 2026
0279411
tests
Brajk19 Jun 4, 2026
a5c47ae
cs/sa
Brajk19 Jun 4, 2026
5e94d7f
wip
Brajk19 Jun 9, 2026
476e8a5
Merge branch '2.x' into closure-if
Brajk19 Jun 11, 2026
b7a9ed6
check if opis/closure is installed
Brajk19 Jun 11, 2026
1d844cd
use native ReflectionFunction when validating if closure
Brajk19 Jun 11, 2026
01657e0
assert that closure is static and does not use outside variables
Brajk19 Jun 11, 2026
b609509
validate if result only for expressions
Brajk19 Jun 11, 2026
6662944
memoize unserialized closures
Brajk19 Jun 11, 2026
fb52de7
update docs
Brajk19 Jun 11, 2026
1286019
update changelog
Brajk19 Jun 11, 2026
1a52072
cs
Brajk19 Jun 11, 2026
1b65a29
phpstan
Brajk19 Jun 11, 2026
1687dee
fix test
Brajk19 Jun 11, 2026
251bd33
use symfony/deepclone instead of opis/closure
Brajk19 Jun 27, 2026
0371718
lint
Brajk19 Jun 27, 2026
4e47e07
phpstan
Brajk19 Jun 27, 2026
51a2153
Merge branch '2.x' into closure-if
Brajk19 Jun 27, 2026
215f034
fix composer.json
Brajk19 Jun 27, 2026
b0cc264
fix tests
Brajk19 Jun 27, 2026
85f9d76
fix tests
Brajk19 Jun 27, 2026
ea2c49f
cleanup
Brajk19 Jun 27, 2026
1de142a
Merge branch 'closure-if' into closure-inverse
Brajk19 Jun 27, 2026
deb4291
cleanup
Brajk19 Jun 27, 2026
b192b59
fix services.php
Brajk19 Sep 22, 2026
21bbe0f
assert inverse is readable
Brajk19 Sep 22, 2026
9ccd76f
add tests
Brajk19 Sep 22, 2026
5702cfa
Merge branch '2.x' into closure-if
Brajk19 Sep 22, 2026
48f318d
allow only anonymous closures
Brajk19 Sep 22, 2026
1b4299b
update DebugCommand
Brajk19 Sep 22, 2026
cf8649f
update baseline
Brajk19 Sep 22, 2026
1681e7f
baseline
Brajk19 Sep 22, 2026
73933ee
fix failing test
Brajk19 Sep 22, 2026
ebdca70
alphabetical order
Brajk19 Sep 22, 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
in https://github.com/sofascore/purgatory-bundle/pull/112
- Ability to pass a static method callable as a `DynamicValues` provider by @HypeMC
in https://github.com/sofascore/purgatory-bundle/pull/137
- Ability to use a closure as the `PurgeOn` `if` condition on PHP 8.5+ by @Brajk19
in https://github.com/sofascore/purgatory-bundle/pull/116

### Changed

Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,17 @@
"symfony/filesystem": "^6.4 || ^7.4 || ^8.1",
"symfony/http-client": "^6.4 || ^7.4 || ^8.1",
"symfony/messenger": "^6.4 || ^7.4 || ^8.1",
"symfony/polyfill-deepclone": "^1.39",
"symfony/process": "^6.4 || ^7.4 || ^8.1",
"symfony/serializer": "^6.4 || ^7.4 || ^8.1",
"symfony/yaml": "^6.4 || ^7.4 || ^8.1"
},
"suggest": {
"symfony/deepclone": "Faster, native PHP extension alternative to symfony/polyfill-deepclone for using a closure as the \"if\" condition of a #[PurgeOn] attribute (PHP 8.5+)",
"symfony/expression-language": "To use expressions in the #[PurgeOn] attribute, e.g. for the \"if\" condition or route parameters",
"symfony/http-client": "To use the Varnish purger",
"symfony/messenger": "To purge URLs asynchronously",
"symfony/polyfill-deepclone": "To use a closure as the \"if\" condition of a #[PurgeOn] attribute (PHP 8.5+)",
"symfony/serializer": "To resolve purge targets from serialization groups with `ForGroups`",
"symfony/yaml": "To configure purge subscriptions using YAML files"
},
Expand Down
4 changes: 2 additions & 2 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@
service('sofascore.purgatory.configuration_loader'),
service('sofascore.purgatory.expression_language')->nullOnInvalid(),
tagged_locator('purgatory.route_param_value_resolver', indexAttribute: 'for'),
service('sofascore.purgatory.property_accessor'),
])

->set('sofascore.purgatory.route_provider.created_entity', CreatedEntityRouteProvider::class)
Expand All @@ -182,12 +183,11 @@
->set('sofascore.purgatory.route_provider.removed_entity', RemovedEntityRouteProvider::class)
->parent('sofascore.purgatory.route_provider.abstract')
->tag('purgatory.route_provider')
->arg(3, service('doctrine'))
->arg(4, service('doctrine'))

->set('sofascore.purgatory.route_provider.updated_entity', UpdatedEntityRouteProvider::class)
->parent('sofascore.purgatory.route_provider.abstract')
->tag('purgatory.route_provider')
->arg(3, service('sofascore.purgatory.property_accessor'))

->set('sofascore.purgatory.entity_change_listener', EntityChangeListener::class)
->args([
Expand Down
29 changes: 29 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,35 @@ In this example, the purge will only occur if the post has more than 3,000 upvot

You can also add [custom Expression Language functions](custom-expression-language-functions.md).

### Adding Conditional Logic with Closures

Starting with [PHP 8.5](https://www.php.net/releases/8.5/en.php), closures can be used in attributes, so the same
condition can be written in plain PHP instead of an expression. The closure receives the entity as its only argument:

```php
#[Route('/post/{id<\d+>}', name: 'post_details', methods: 'GET')]
#[PurgeOn(Post::class, if: static function (Post $post): bool {
return $post->upvotes > 3000;
})]
public function detailsAction(Post $post)
{
}
```

This feature requires the [`symfony/polyfill-deepclone`](https://github.com/symfony/polyfill-deepclone) package:

```sh
composer require symfony/polyfill-deepclone
```

For better performance you can instead install the [`symfony/deepclone`](https://github.com/symfony/php-ext-deepclone) PHP extension.

The closure must:

- have exactly one parameter, typed with the subscribed entity class or one of its parents,
- declare a non-nullable `bool` return type.


### Using Purge on Actions with Multiple Routes

By default, the attribute generates URLs for all routes associated with the action. You can limit this to one or more
Expand Down
6 changes: 3 additions & 3 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ parameters:
path: src/Attribute/RouteParamValue/EnumValues.php

-
message: '#^Parameter \#1 \$configuration of class Sofascore\\PurgatoryBundle\\Cache\\Configuration\\Configuration constructor expects array\<non\-empty\-string, list\<array\{routeName\: string, routeParams\?\: array\<string, array\{type\: string, values\: list\<mixed\>, optional\?\: true\}\>, if\?\: string, actions\?\: non\-empty\-list\<Sofascore\\PurgatoryBundle\\Listener\\Enum\\Action\>\}\>\>, mixed given\.$#'
message: '#^Parameter \#1 \$configuration of class Sofascore\\PurgatoryBundle\\Cache\\Configuration\\Configuration constructor expects array\<non\-empty\-string, list\<array\{routeName\: string, routeParams\?\: array\<string, array\{type\: string, values\: list\<mixed\>, optional\?\: true\}\>, if\?\: array\<mixed\>\|string, closureProperty\?\: string, actions\?\: non\-empty\-list\<Sofascore\\PurgatoryBundle\\Listener\\Enum\\Action\>\}\>\>, mixed given\.$#'
identifier: argument.type
count: 1
path: src/Cache/Configuration/CachedConfigurationLoader.php

-
message: '#^Method Sofascore\\PurgatoryBundle\\Cache\\Configuration\\Subscriptions\:\:getIterator\(\) should return Traversable\<int, array\{routeName\: string, routeParams\?\: array\<string, array\{type\: string, values\: list\<mixed\>, optional\?\: true\}\>, if\?\: string, actions\?\: non\-empty\-list\<Sofascore\\PurgatoryBundle\\Listener\\Enum\\Action\>\}\> but returns ArrayIterator\<int, array\{routeName\: string, routeParams\?\: array\<string, array\{type\: string, values\: list\<mixed\>, optional\?\: bool\}\>, if\?\: string, actions\?\: non\-empty\-list\<Sofascore\\PurgatoryBundle\\Listener\\Enum\\Action\>\}\>\.$#'
message: '#^Method Sofascore\\PurgatoryBundle\\Cache\\Configuration\\Subscriptions\:\:getIterator\(\) should return Traversable\<int, array\{routeName\: string, routeParams\?\: array\<string, array\{type\: string, values\: list\<mixed\>, optional\?\: true\}\>, if\?\: array\<mixed\>\|string, closureProperty\?\: string, actions\?\: non\-empty\-list\<Sofascore\\PurgatoryBundle\\Listener\\Enum\\Action\>\}\> but returns ArrayIterator\<int, array\{routeName\: string, routeParams\?\: array\<string, array\{type\: string, values\: list\<mixed\>, optional\?\: bool\}\>, if\?\: array\<mixed\>\|string, closureProperty\?\: string, actions\?\: non\-empty\-list\<Sofascore\\PurgatoryBundle\\Listener\\Enum\\Action\>\}\>\.$#'
identifier: return.type
count: 1
path: src/Cache/Configuration/Subscriptions.php
Expand Down Expand Up @@ -73,7 +73,7 @@ parameters:
path: src/Cache/RouteMetadata/YamlMetadataProvider.php

-
message: '#^Comparison operation "\<" between 80105 and 80100 is always false\.$#'
message: '#^Comparison operation "\<" between \d+ and 80100 is always false\.$#'
identifier: smaller.alwaysFalse
count: 1
path: src/PurgatoryBundle.php
Expand Down
8 changes: 6 additions & 2 deletions src/Attribute/PurgeOn.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ final class PurgeOn
public readonly ?TargetInterface $target;
/** @var ?non-empty-array<string, ValuesInterface> */
public readonly ?array $routeParams;
public readonly ?Expression $if;
public readonly \Closure|Expression|null $if;
/** @var ?non-empty-list<string> */
public readonly ?array $route;
/** @var ?non-empty-list<Action> */
Expand All @@ -35,10 +35,14 @@ public function __construct(
public readonly string $class,
string|array|TargetInterface|null $target = null,
?array $routeParams = null,
string|Expression|null $if = null,
\Closure|string|Expression|null $if = null,
string|array|null $route = null,
string|array|Action|null $actions = null,
) {
if ($if instanceof \Closure && !\function_exists('deepclone_to_array')) {
throw new LogicException('You cannot use a closure for the "if" attribute because deepclone support is not available. Install the "symfony/polyfill-deepclone" package (run "composer require symfony/polyfill-deepclone") or the "symfony/deepclone" PHP extension (https://github.com/symfony/php-ext-deepclone).');
}

$this->target = \is_array($target) || \is_string($target) ? new ForProperties($target) : $target;
$this->routeParams = null !== $routeParams ? self::normalizeRouteParams($routeParams) : null;
$this->if = \is_string($if) ? self::normalizeExpression($if) : $if;
Expand Down
6 changes: 4 additions & 2 deletions src/Cache/Configuration/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ final class Configuration implements \Countable
* @param array<non-empty-string, list<array{
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* if?: string|array<mixed>,
* closureProperty?: string,
* actions?: non-empty-list<Action>,
* }>> $configuration
*/
Expand Down Expand Up @@ -56,7 +57,8 @@ public function count(): int
* @return array<non-empty-string, list<array{
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* if?: string|array<mixed>,
* closureProperty?: string,
* actions?: non-empty-list<Action>,
* }>>
*/
Expand Down
10 changes: 9 additions & 1 deletion src/Cache/Configuration/ConfigurationLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,15 @@ public function load(): Configuration
}

if (null !== $subscription->if) {
$config['if'] = (string) $subscription->if;
if ($subscription->if instanceof \Closure) {
$config['if'] = deepclone_to_array($subscription->if);

if (null !== $subscription->closurePropertyPath) {
$config['closureProperty'] = $subscription->closurePropertyPath;
}
} else {
$config['if'] = (string) $subscription->if;
}
}

if (null !== $subscription->actions) {
Expand Down
8 changes: 5 additions & 3 deletions src/Cache/Configuration/Subscriptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* @implements \IteratorAggregate<int, array{
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* if?: string|array<mixed>,
* closureProperty?: string,
* actions?: non-empty-list<Action>,
* }>
*/
Expand All @@ -21,7 +22,8 @@ final class Subscriptions implements \IteratorAggregate, \Countable
* @param list<array{
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* if?: string|array<mixed>,
* closureProperty?: string,
* actions?: non-empty-list<Action>,
* }> $subscriptions
*/
Expand Down Expand Up @@ -53,7 +55,7 @@ public function key(): string
* @return list<array{
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* if?: string|array<mixed>,
* actions?: non-empty-list<Action>,
* }>
*/
Expand Down
12 changes: 11 additions & 1 deletion src/Cache/PropertyResolver/AssociationResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,17 @@ public function resolveSubscription(
?? $values;
}

$closurePropertyPath = null;

if (null !== $if = $routeMetadata->purgeOn->if) {
$if = $this->expressionTransformer->transform($if, $associationClass, $associationTarget, 'false');
if ($if instanceof \Closure) {
// The closure expects the original entity, but the inverse subscription fires on the
// associated entity. Carry the inverse field so it can be navigated back at runtime.
$this->expressionTransformer->assertReadable($associationClass, $associationTarget);
$closurePropertyPath = $associationTarget;
} else {
$if = $this->expressionTransformer->transform($if, $associationClass, $associationTarget, 'false');
}
}

yield new PurgeSubscription(
Expand All @@ -84,6 +93,7 @@ class: $associationClass,
route: $routeMetadata->route,
actions: $routeMetadata->purgeOn->actions,
if: $if,
closurePropertyPath: $closurePropertyPath,
);

return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ public function transform(Expression $expression, string $class, string $propert
return new Expression("obj.$getter !== null ? ($inverseExpression) : $fallback");
}

public function assertReadable(string $class, string $property): void
{
$this->createGetter($class, $property);
}

private function createGetter(string $class, string $property): string
{
if (null === $readInfo = $this->extractor->getReadInfo($class, $property)) {
Expand Down
6 changes: 5 additions & 1 deletion src/Cache/Subscription/PurgeSubscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ final class PurgeSubscription
* @param class-string $class
* @param array<string, ValuesInterface> $routeParams
* @param ?non-empty-list<Action> $actions
* @param ?string $closurePropertyPath property path used to navigate from the changed
* entity to the entity the closure expects, when the
* closure `if` is attached to an inverse subscription
*/
public function __construct(
public readonly string $class,
Expand All @@ -23,7 +26,8 @@ public function __construct(
public readonly string $routeName,
public readonly Route $route,
public readonly ?array $actions,
public readonly ?Expression $if = null,
public readonly \Closure|Expression|null $if = null,
public readonly ?string $closurePropertyPath = null,
) {
}
}
53 changes: 52 additions & 1 deletion src/Cache/Subscription/PurgeSubscriptionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Sofascore\PurgatoryBundle\Cache\RouteMetadata\RouteMetadataProviderInterface;
use Sofascore\PurgatoryBundle\Cache\TargetResolver\TargetResolverInterface;
use Sofascore\PurgatoryBundle\Exception\EntityMetadataNotFoundException;
use Sofascore\PurgatoryBundle\Exception\InvalidIfClosureException;
use Sofascore\PurgatoryBundle\Exception\InvalidIfExpressionException;
use Sofascore\PurgatoryBundle\Exception\MissingRequiredRouteParametersException;
use Sofascore\PurgatoryBundle\Exception\TargetSubscriptionNotResolvableException;
Expand Down Expand Up @@ -59,7 +60,7 @@ private function provideFromMetadata(RouteMetadataProviderInterface $routeMetada
$purgeOn = $routeMetadata->purgeOn;

if (null !== $purgeOn->if) {
$this->validateExpression($purgeOn->if, $routeMetadata->routeName);
$this->validateIf($purgeOn->if, $routeMetadata->routeName, $purgeOn->class);
}

// if route parameters are not specified, they are same as path variables
Expand Down Expand Up @@ -146,6 +147,56 @@ private function validateRouteParams(array $routeParams, RouteMetadata $routeMet
}
}

private function validateIf(\Closure|Expression $expression, string $routeName, string $entity): void
{
if ($expression instanceof \Closure) {
$this->validateIfClosure($expression, $routeName, $entity);

return;
}

$this->validateExpression($expression, $routeName);
}

private function validateIfClosure(\Closure $expression, string $routeName, string $entity): void
{
$reflection = new \ReflectionFunction($expression);

if (null !== $reflection->getClosureThis()) {
throw new InvalidIfClosureException($routeName, 'Closure must be static');
}

if ([] !== $reflection->getClosureUsedVariables()) {
throw new InvalidIfClosureException($routeName, 'Closure must not capture variables');
}

if (!$reflection->isAnonymous()) {
throw new InvalidIfClosureException($routeName, 'First-class callables are not supported, use a static closure');
}

$returnType = $reflection->getReturnType();

if (!$returnType instanceof \ReflectionNamedType
|| $returnType->allowsNull()
|| !\in_array($returnType->getName(), ['bool', 'true', 'false'])
) {
throw new InvalidIfClosureException($routeName, 'Return type must be bool');
}

if (1 !== $reflection->getNumberOfParameters()) {
throw new InvalidIfClosureException($routeName, 'Closure must have exactly 1 parameter');
}

$parameterType = $reflection->getParameters()[0]->getType();

if (!$parameterType instanceof \ReflectionNamedType
|| $parameterType->allowsNull()
|| !is_a($entity, $parameterType->getName(), true)
) {
throw new InvalidIfClosureException($routeName, "Parameter in closure must be of type $entity");
}
}

private function validateExpression(Expression $expression, string $routeName): void
{
try {
Expand Down
Loading
Loading