Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 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
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
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 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
},
"require-dev": {
"doctrine/common": "^3.2",
"opis/closure": "^4.3",
"phpunit/phpunit": "^12.5",
"symfony/cache": "^6.4 || ^7.4 || ^8.0",
"symfony/doctrine-messenger": "^6.4 || ^7.4 || ^8.0",
Expand Down
27 changes: 27 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,33 @@ 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 [`opis/closure`](https://github.com/opis/closure) package:

```sh
composer require opis/closure
```

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
4 changes: 2 additions & 2 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\?\: string, closureIf\?\: true, 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\?\: string, closureIf\?\: true, 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, closureIf\?\: bool, actions\?\: non\-empty\-list\<Sofascore\\PurgatoryBundle\\Listener\\Enum\\Action\>\}\>\.$#'
identifier: return.type
count: 1
path: src/Cache/Configuration/Subscriptions.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('Opis\Closure\serialize')) {
throw new LogicException('You cannot use a closure for the "if" attribute because the "opis/closure" package is not installed. Try running "composer require opis/closure".');
}

$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
2 changes: 2 additions & 0 deletions src/Cache/Configuration/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ final class Configuration implements \Countable
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* closureIf?: true,
* actions?: non-empty-list<Action>,
* }>> $configuration
*/
Expand Down Expand Up @@ -57,6 +58,7 @@ public function count(): int
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* closureIf?: true,
* actions?: non-empty-list<Action>,
* }>>
*/
Expand Down
9 changes: 8 additions & 1 deletion src/Cache/Configuration/ConfigurationLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use Sofascore\PurgatoryBundle\Cache\Subscription\PurgeSubscriptionProviderInterface;
use Symfony\Component\Routing\Route;

use function Opis\Closure\serialize;

final class ConfigurationLoader implements ConfigurationLoaderInterface
{
public function __construct(
Expand Down Expand Up @@ -38,7 +40,12 @@ public function load(): Configuration
}

if (null !== $subscription->if) {
$config['if'] = (string) $subscription->if;
if ($subscription->if instanceof \Closure) {
$config['if'] = serialize($subscription->if);
$config['closureIf'] = true;
} else {
$config['if'] = (string) $subscription->if;
}
}

if (null !== $subscription->actions) {
Expand Down
3 changes: 3 additions & 0 deletions src/Cache/Configuration/Subscriptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* closureIf?: true,
* actions?: non-empty-list<Action>,
* }>
*/
Expand All @@ -22,6 +23,7 @@ final class Subscriptions implements \IteratorAggregate, \Countable
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* closureIf?: true,
* actions?: non-empty-list<Action>,
* }> $subscriptions
*/
Expand Down Expand Up @@ -54,6 +56,7 @@ public function key(): string
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* closureIf?: true,
* actions?: non-empty-list<Action>,
* }>
*/
Expand Down
5 changes: 5 additions & 0 deletions src/Cache/PropertyResolver/AssociationResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ public function resolveSubscription(
}

if (null !== $if = $routeMetadata->purgeOn->if) {
if ($if instanceof \Closure) {
// TODO support closures
throw new \RuntimeException('Cannot create inverse subscription with closures');
Comment on lines +77 to +78

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

will be implemented in following PR because this one is already big

}

$if = $this->expressionTransformer->transform($if, $associationClass, $associationTarget, 'false');
}

Expand Down
2 changes: 1 addition & 1 deletion src/Cache/Subscription/PurgeSubscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ 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,
) {
}
}
49 changes: 48 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,52 @@ 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');
}

$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
18 changes: 17 additions & 1 deletion src/Command/DebugCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\Mapping\ClassMetadata;
use Opis\Closure\Box;
use Opis\Closure\ReflectionClosure;
use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\CompoundValues;
use Sofascore\PurgatoryBundle\Cache\Configuration\Configuration;
use Sofascore\PurgatoryBundle\Cache\Configuration\ConfigurationLoaderInterface;
Expand All @@ -18,6 +20,8 @@
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

use function Opis\Closure\unserialize;

#[AsCommand(
name: 'purgatory:debug',
description: 'Display purge subscription information for an entity or multiple entities',
Expand Down Expand Up @@ -249,6 +253,7 @@ private function findSubscriptionsForRoute(string $routeName): array
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* closureIf?: true,
* actions?: non-empty-list<Action>,
* }>> $configuration
*/
Expand All @@ -260,14 +265,25 @@ private function display(SymfonyStyle $io, array $configuration): void
$entity = explode('::', $key);

foreach ($subscriptions as $subscription) {
if (isset($subscription['closureIf'], $subscription['if'])) {
/** @var \Closure $closure */
$closure = unserialize($subscription['if'], options: ['allowed_classes' => [Box::class]]);
$r = new ReflectionClosure($closure);
$closureBody = $r->info()->getIncludePHP(false);

$if = rtrim(substr($closureBody, strpos($closureBody, 'return ') + \strlen('return ')), ';');
} else {
$if = $subscription['if'] ?? 'NONE';
}

$io->table(
['Option', 'Value'],
[
['Entity', $entity[0]],
['Property', $entity[1] ?? 'ANY'],
['Route Name', $subscription['routeName']],
['Route Params', isset($subscription['routeParams']) ? $this->formatRouteParams($subscription['routeParams']) : 'NONE'],
['Condition', $subscription['if'] ?? 'NONE'],
['Condition', $if],
['Actions', isset($subscription['actions']) ? $this->formatActions($subscription['actions']) : 'ANY'],
],
);
Expand Down
15 changes: 15 additions & 0 deletions src/Exception/InvalidIfClosureException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Sofascore\PurgatoryBundle\Exception;

final class InvalidIfClosureException extends InvalidArgumentException
{
public function __construct(
public readonly string $routeName,
string $message,
) {
parent::__construct("Invalid 'if' closure for route '$routeName': $message");
}
}
28 changes: 25 additions & 3 deletions src/RouteProvider/AbstractEntityRouteProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Sofascore\PurgatoryBundle\RouteProvider;

use Doctrine\ORM\PersistentCollection;
use Opis\Closure\Box;
use Psr\Container\ContainerInterface;
use Sofascore\PurgatoryBundle\Cache\Configuration\Configuration;
use Sofascore\PurgatoryBundle\Cache\Configuration\ConfigurationLoaderInterface;
Expand All @@ -15,6 +16,8 @@
use Sofascore\PurgatoryBundle\RouteParamValueResolver\ValuesResolverInterface;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;

use function Opis\Closure\unserialize;

/**
* @internal
*
Expand All @@ -31,6 +34,9 @@ abstract protected function getChangedProperties(object $entity, array $entityCh

private ?Configuration $configuration = null;

/** @var array<string, \Closure> */
private array $unserializedClosures = [];

public function __construct(
private readonly ConfigurationLoaderInterface $configurationLoader,
private readonly ?ExpressionLanguage $expressionLanguage,
Expand Down Expand Up @@ -74,9 +80,17 @@ private function processValidSubscriptions(Subscriptions $subscriptions, array $
}

if (isset($subscription['if'])) {
$result = $this->getExpressionLanguage()->evaluate($subscription['if'], ['obj' => $entity]);
if (!\is_bool($result)) {
throw new InvalidIfExpressionResultException($subscription['routeName'], $subscription['if'], $result);
if (isset($subscription['closureIf'])) {
$closure = $this->unserializedClosures[$subscription['if']] ??= $this->unserializeClosure($subscription['if']);

/** @var bool $result */
$result = $closure($entity);
} else {
$result = $this->getExpressionLanguage()->evaluate($subscription['if'], ['obj' => $entity]);

if (!\is_bool($result)) {
throw new InvalidIfExpressionResultException($subscription['routeName'], $subscription['if'], $result);
}
}

if (!$result) {
Expand Down Expand Up @@ -170,4 +184,12 @@ private function getExpressionLanguage(): ExpressionLanguage
return $this->expressionLanguage
?? throw new LogicException('You cannot use expressions because the Symfony ExpressionLanguage component is not installed. Try running "composer require symfony/expression-language".');
}

private function unserializeClosure(string $serializedClosure): \Closure
{
/** @var \Closure $closure */
$closure = unserialize($serializedClosure, options: ['allowed_classes' => [Box::class]]);

return $closure;
}
}
Loading
Loading