yuriitatur / strategies
A simple package to update entities in certain declarative way
Requires
- php: >=8.4
- psr/event-dispatcher: ^1.0.0
- psr/log: ^3.0
- symfony/event-dispatcher: ^8.0
- yuriitatur/exceptions: ^1.0
- yuriitatur/pipeline: ^1.0
- yuriitatur/repository: ^0.6
Requires (Dev)
- dg/bypass-finals: ^1.3
- jms/serializer: ^3.32
- kint-php/kint: ^6.0
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^13
- psr/simple-cache: ^3.0
- symfony/framework-bundle: ^8.0
- symfony/serializer: ^7.3
- symfony/yaml: ^7.3
Suggests
- jms/serializer: Adds ability to use JmsSerializerDehydrator
- laravel/framework: Integration auto-discovered through StrategyServiceProvider (Laravel 13)
- psr/simple-cache: Allows to use CachedStrategyMetadata
- symfony/framework-bundle: Integration through StrategiesBundle (Symfony 8)
- symfony/serializer: Allows to use SymfonySerializerDehydrator and SymfonyStateNormalizer
- symfony/yaml: Allows to use YamlStrategyProvider
- yuriitatur/repository-laravel: Binds TransactionRunnerInterface in Laravel, needed by RunInTransaction
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-30 11:55:31 UTC
README
Simple entity strategy package
Installation
Composer:
composer require yuriitatur/strategies
Usage
// $manager instanceOf ChangesManagerInterface
$context = $manager->initialize($entity); // take a snapshot of the entity
$entity->setNewValue(123); // modify data
$context->apply(); // detect the changes and apply the matching strategy
This compares the entity with its state at initialize(), walks the strategies available for the entity in order
and applies the first one whose preconditions are all met. If none of them match,
LastResort is applied, which only logs a warning and dispatches
LastResortStrategyAppliedEvent.
A context is applied once: calling apply() again (even from inside the strategy) does nothing and returns null.
That holds even when the strategy threw, so to retry, initialize() the entity again.
Before applying, a context can be inspected; nothing is applied and no event is dispatched:
$context->getChanges(); // array<string, Change>, keyed by top-level property
$context->hasChanges();
echo $context->explain(); // every candidate strategy and the precondition that rejected it
// Changed: status
// App\Strategies\ShipOrder: rejected by YuriiTatur\...\ChangedTo("status", "shipped")
// App\Strategies\PayOrder: selected
One change, one strategy
Only one strategy is ever applied, on purpose: every change is atomic. List each state change you support as its own
strategy, so the set of changed fields picks exactly one of them (e.g. status changed is ShipOrder, status and
address changed is ShipOrderToNewAddress). Running several strategies would mean several saves and no clear answer
to which change caused which. To react to a change in more ways than one, listen to the events below.
Events
All of them are dispatched through the PSR-14 dispatcher given to ChangesManager:
| Event | When |
|---|---|
ListeningToChangesEvent | initialize() took the snapshot |
StrategyFoundEvent | a strategy was picked, before it is applied |
StrategyAppliedEvent | the strategy and its middleware finished |
StrategyFailedEvent | the strategy or its middleware threw; the exception is rethrown after the event |
LastResortStrategyAppliedEvent | no strategy matched and LastResort ran |
StrategyFoundEvent::$strategy is writable, and the manager applies whatever strategy the event holds after the
listeners ran. This is intended, for example to wrap the strategy picked for an order status change in a decorator that
also sends an email. The replacement's preconditions are not checked.
No matching strategy
LastResort does nothing. For a strict mode, use ThrowException, which throws
StrategyNotFoundException. It has no preconditions, so either list it as the entity's last strategy:
#[Strategy(ShipOrder::class, 1)]
#[Strategy(ThrowException::class, 100)]
class Order implements Entity {}
or put it in place of LastResort for every entity:
$dispatcher->addListener(StrategyFoundEvent::class, function (StrategyFoundEvent $event) {
if ($event->strategy instanceof LastResort) {
$event->strategy = new ThrowException;
}
});
Change detection
initialize() stores the entity's state as plain data: scalars, nulls and arrays only. apply() takes the same
snapshot again and compares the two with ===. Objects are turned into data by normalizers, the first one that
supports an object wins: enums, dates (as the instant in UTC), JsonSerializable, __serialize(), Stringable and,
for any other object, its properties. Put your own normalizers first. For example, keep services an entity holds out of
the snapshot with ClassNameNormalizer, which reduces every object whose class is in one of the given namespaces to
its class name:
$detector = new ChangeDetector(new ReflectionDehydrator, new StateNormalizer([
new ClassNameNormalizer(['App\\Repositories\\', 'Symfony\\']),
...StateNormalizer::defaults(),
]));
Only the object's own class counts, not its interfaces. Avoid namespaces that also hold values you track: with
'Illuminate\\', a change inside an Illuminate\Support\Collection or an Illuminate\Support\Carbon would go unnoticed.
ChangeDetector takes any StateNormalizerInterface. To use symfony/serializer and its normalizers instead, pass
SymfonyStateNormalizer. It works with your own Serializer and context too; by default dates are normalized the
same way (the instant in UTC, with microseconds) and cycles are cut:
$detector = new ChangeDetector(new ReflectionDehydrator, new SymfonyStateNormalizer(
new Serializer([new MyMoneyNormalizer, ...SymfonyStateNormalizer::defaults()]),
));
Strategy
The strategy is an object of a class implementing StrategyInterface.
It has only one method - apply which applies it. You can add conditions to it by applying
PreCondition attribute and specify the condition under what this strategy may be applied.
After all preconditions are met, the strategy is applied. You can manipulate it by specifying middlewares
BeforeApply, AfterApply and
OverApply, which, as you can guess, are applied before, after or have full
control around the apply method.
#[PreCondition(RequiresEntityId::class)]
#[PreCondition(HasChanged::class, ['status'])]
#[BeforeApply(SaveEntity::class)]
class ShipOrder implements StrategyInterface
{
public function apply(Entity $order)
{
event(new OrderShipped($order));
}
}
Preconditions
Built in, besides the ones CreateStrategy, UpdateStrategy and DeleteStrategy use:
| Precondition | Arguments | Met when |
|---|---|---|
HasChanged / ChangedAny | fields | any of them changed |
HasNotChanged | fields | none of them changed |
OnlyChanged | fields | something changed, and nothing outside them |
ChangedTo | field, value | the field changed to the value |
ChangedFrom | field, value | the field changed from the value |
Transition | field, from, to | the field changed from one value to the other |
AllOf, AnyOf | preconditions | all of them, or at least one of them, are met |
Not | precondition | it is not met |
Values are normalized like the entity, so an enum case matches its backed value and a date its instant:
#[PreCondition(Transition::class, ['status', OrderStatus::Paid, OrderStatus::Shipped])]
#[PreCondition(AnyOf::class, [
new PreCondition(ChangedTo::class, ['address.city', 'Kyiv']),
new PreCondition(Not::class, [MustBeDeleted::class]),
])]
Fields are paths into the normalized state: status, address.city (a property of a nested object), lines.0.qty
(a list item) or lines[], which is the list's members regardless of their order: it changes only when items are added
or removed (an item that changed counts as both). A Change gives the same access: $change->at('city'), $change->paths() (every nested change by path),
$change->added() and $change->removed() (list items); Utilities\Changes::at($changes, 'address.city') and
Changes::paths($changes) do it for a whole set of changes. The combinators get their inner preconditions through the
ResolverInterface, so make it resolvable from your container.
Preconditions and middleware are resolved from your container (see ContainerResolver), so they can take any
service they need, e.g. the current user:
class AdminOnly implements PreConditionInterface
{
public function __construct(private readonly Guard $auth)
{
}
public function isMet(array $changes, Entity $changedEntity, ...$args): bool
{
return $this->auth->user()?->isAdmin() ?? false;
}
}
Like PreCondition, the middleware attributes take arguments. BeforeApply and AfterApply services get them after
the entity, OverApply services after the entity and the next closure:
#[BeforeApply(Notify::class, ['warehouse'])] // Notify::__invoke(Entity $order, string $channel)
#[OverApply(Throttle::class, [10])] // Throttle::__invoke(Entity $order, Closure $next, int $perMinute)
Attributes are inherited: a strategy gets the preconditions and middleware declared on its parent classes too, so extending CreateStrategy, UpdateStrategy or DeleteStrategy adds their checks and saving/deleting to yours. A class's own middleware wraps the middleware inherited from its parents.
To apply a strategy and its saving in one transaction, add
RunInTransaction as OverApply. It needs a
TransactionRunnerInterface from yuriitatur/repository in your container (in Laravel,
yuriitatur/repository-laravel binds one). Declared on your own class, it wraps the
middleware inherited from UpdateStrategy, SaveEntity included:
#[OverApply(RunInTransaction::class)]
#[PreCondition(HasChanged::class, ['status'])]
class ShipOrder extends UpdateStrategy
{
public function apply(Entity $order): Entity
{
$order->shippedAt = new DateTimeImmutable;
return $order;
}
}
Strategy metadata cache
The precondition and middleware attributes of a strategy class are read once per process. To read them once per deploy,
give the locator a CachedStrategyMetadata with a PSR-16 cache. Its entries never expire unless a ttl is given, so
clear them, or change the prefix (e.g. per release), whenever strategy classes change:
$locator = new StrategyLocator($provider, $resolver, new CachedStrategyMetadata($cache, ttl: null, prefix: 'strategies.v42.'));
Testing your code
Testing\FakeChangesManager picks strategies like ChangesManager but only records them: nothing is applied, no
middleware runs and no event is dispatched. Type your code against ChangesManagerInterface to be able to swap it in:
$fake = new FakeChangesManager($detector, $locator);
// ... run the code under test with $fake ...
$fake->assertStrategyApplied(ShipOrder::class, fn(array $changes, Entity $order) => $changes['status']->to === 'shipped');
$fake->assertStrategyNotApplied(CancelOrder::class);
$fake->assertNothingApplied();
Laravel
StrategyServiceProvider is auto-discovered (Laravel 13). It binds ChangesManager (and ChangesManagerInterface) and
everything it needs:
ReflectionDehydrator, StateNormalizer, ContainerResolver, a MappedStrategyProvider (attributes, then the
config map, then MethodStrategyProviderInterface) and a Symfony PSR-14 dispatcher whose events are also dispatched
through Laravel's. Each binding is skipped if your application already has one. For RunInTransaction, install
yuriitatur/repository-laravel, which binds TransactionRunnerInterface. To change the normalizers or map strategies in config:
php artisan vendor:publish --tag=strategies-config
The config's metadata_cache.store names a cache store for CachedStrategyMetadata.
Two Laravel preconditions are included: Authenticated (optionally on a guard, [Authenticated::class, ['api']]) and
UserCan, which asks the gate for an ability with the entity and any further arguments, so the entity's policy applies:
#[PreCondition(UserCan::class, ['ship'])]
#[PreCondition(ChangedTo::class, ['status', OrderStatus::Shipped])]
class ShipOrder extends UpdateStrategy {}
In tests, the Laravel\Testing\FakesStrategies trait swaps ChangesManagerInterface for a fake:
$fake = $this->fakeStrategies();
$this->post('/orders/1/ship');
$fake->assertStrategyApplied(ShipOrder::class);
Symfony
Register YuriiTatur\Strategies\Symfony\StrategiesBundle (Symfony 8). It registers ChangesManager (public as
ChangesManagerInterface) with the same parts as the Laravel provider, using the PSR-14 event_dispatcher and
logger. Strategies, preconditions, middleware and strategy providers are resolved from a service locator of the
services tagged strategies.service. Services implementing StrategyInterface, PreConditionInterface,
MiddlewareInterface or StrategyProviderInterface are tagged by autoconfiguration; tag plain callables used as
BeforeApply/AfterApply/OverApply services yourself. The built-in ones are registered already. Bind
RepositoryInterface for SaveEntity/DeleteEntity and TransactionRunnerInterface for RunInTransaction.
# config/packages/strategies.yaml
strategies:
class_name_namespaces: ['Doctrine\']
normalizers: [App\Normalizer\MoneyNormalizer] # service ids, checked before the defaults
strategies:
App\Entity\Invoice: [App\Strategy\PayInvoice, YuriiTatur\Strategies\Strategies\ThrowException]
metadata_cache:
service: app.strategies_cache # a PSR-16 service, e.g. a Psr16Cache over cache.app
prefix: strategies.metadata.
ttl: ~
Testing
composer test
tests/unit holds the unit tests, tests/feature/Laravel the Laravel integration tests (Orchestra Testbench) and
tests/feature/Symfony the bundle's.
License
This code is under MIT license, read more in the LICENSE file.