Search by

yuriitatur / strategies

yuriitatur

A simple package to update entities in certain declarative way

Package info

bitbucket.org/yurii_tatur/strategies

pkg:composer/yuriitatur/strategies

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

v0.1.0 2026-10-30 11:55 UTC

This package is auto-updated.

Last update: 2026-10-30 11:55:31 UTC


README

Quality Gate Status Coverage

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:

EventWhen
ListeningToChangesEventinitialize() took the snapshot
StrategyFoundEventa strategy was picked, before it is applied
StrategyAppliedEventthe strategy and its middleware finished
StrategyFailedEventthe strategy or its middleware threw; the exception is rethrown after the event
LastResortStrategyAppliedEventno 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:

PreconditionArgumentsMet when
HasChanged / ChangedAnyfieldsany of them changed
HasNotChangedfieldsnone of them changed
OnlyChangedfieldssomething changed, and nothing outside them
ChangedTofield, valuethe field changed to the value
ChangedFromfield, valuethe field changed from the value
Transitionfield, from, tothe field changed from one value to the other
AllOf, AnyOfpreconditionsall of them, or at least one of them, are met
Notpreconditionit 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.