From f70bd1d6dedea43c76cbaf03557a42f61c905e97 Mon Sep 17 00:00:00 2001 From: adrianmiu Date: Sat, 6 Jun 2026 13:52:07 +0300 Subject: [PATCH 1/7] Upgrade to PHP 8.3+ with modernized code and tooling Drop PHP 8.0-8.2 in favor of PHP 8.3/8.4/8.5 and upgrade the dev tooling: PHPUnit 9 -> 12, PHPStan 1 -> 2, and add Rector 2. - composer.json: php ^8.3; phpunit ^12, phpstan ^2, rector ^2; move friendsofphp/php-cs-fixer out of tools/ into require-dev; add rector scripts; fix csfix to use php-cs-fixer's --rules=@PSR2. - phpunit.xml: migrate to the PHPUnit 12 schema (drop removed attributes, move source coverage into ). - phpstan.neon: drop removed checkGenericClassInNonGenericObjectType key. - rector.php: PHP 8.3 set + code-quality/dead-code/type-declaration/ early-return; skip ReadOnlyPropertyRector and CallableCollection promotion. - src: #[\Override] attributes, readonly value objects, ::class over get_class(), constructor promotion, typed properties, strict_types. - Dockerfile: base php:8.3-fpm; fix two pre-existing bugs (swallowed Composer install and clobbered xdebug ini that disabled coverage). - CI: matrix 8.3/8.4/8.5, bump actions to v4, drop deprecated --no-suggest, run rector:dry; docs.yml pins PHP via setup-php. Note: making value-object properties readonly is a minor BC break. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 11 +++---- .github/workflows/docs.yml | 7 +++- Dockerfile | 8 +++-- composer.json | 16 ++++++--- docker-compose.yml | 4 +-- phpstan.neon | 1 - rector.php | 33 +++++++++++++++++++ src/ArgumentReference.php | 2 +- src/CallableCollection.php | 7 ++-- src/Event/Dispatcher.php | 15 +++++---- src/Event/ListenerProvider.php | 7 ++-- src/Event/Stoppable.php | 6 ++++ src/InvalidCallableException.php | 2 ++ src/Invoker.php | 7 ++-- src/InvokerReference.php | 2 +- src/InvokerResult.php | 3 +- src/Modifiers/LimitArguments.php | 2 +- src/Modifiers/Once.php | 8 ++--- src/Modifiers/ResolveArguments.php | 5 +-- src/Modifiers/WithArguments.php | 2 +- src/Modifiers/Wrap.php | 5 ++- src/PipelinePromise.php | 2 +- src/Processors/ActionsProcessor.php | 2 +- src/Processors/CommandBus.php | 9 ++--- src/Processors/FiltersProcessor.php | 2 +- src/Processors/MiddlewareProcessor.php | 3 +- src/Processors/PipelineProcessor.php | 1 + src/Processors/SimpleCallablesProcessor.php | 2 +- src/SuggestedResume.php | 2 +- src/SuggestedRetry.php | 2 +- tests/phpunit.xml | 18 ++++------ tests/phpunit_bootstrap.php | 2 ++ tests/src/CommandBus/SampleCommand.php | 2 ++ tests/src/CommandBus/SampleHandler.php | 4 ++- tests/src/Event/DispatcherTest.php | 20 +++++------ tests/src/Event/EventWithName.php | 2 ++ tests/src/Event/EventWithoutName.php | 2 ++ tests/src/Event/StoppableEvent.php | 2 ++ tests/src/Modifiers/OnceTest.php | 4 +-- tests/src/Modifiers/ResolveArgumentsTest.php | 4 +-- tests/src/Modifiers/WithArgumentsTest.php | 8 ++--- tests/src/Modifiers/WrapTest.php | 4 +-- tests/src/Processors/ActionsProcessorTest.php | 4 +-- tests/src/Processors/CommandBusTest.php | 18 ++++------ tests/src/Processors/FiltersProcessorTest.php | 6 ++-- .../Processors/MiddlewareProcessorTest.php | 14 +++----- .../src/Processors/PipelineProcessorTest.php | 6 ++-- .../Processors/SimpleStackProcessorTest.php | 8 ++--- tests/src/StackTest.php | 4 +-- tests/src/TestCase.php | 11 ++++--- tests/src/Utilities/DependencyClass.php | 4 ++- tests/src/Utilities/DependentClass.php | 4 ++- tests/src/Utilities/SimpleCallables.php | 10 +++--- tools/php-cs-fixer/composer.json | 5 --- 54 files changed, 196 insertions(+), 148 deletions(-) create mode 100644 rector.php delete mode 100644 tools/php-cs-fixer/composer.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df25d92..65286e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,14 +10,12 @@ jobs: build: strategy: matrix: - php: ['8.0', '8.1', '8.2', '8.3'] - include: - - php: '8.0' + php: ['8.3', '8.4', '8.5'] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Setup PHP with fail-fast uses: shivammathur/setup-php@v2 with: @@ -31,7 +29,7 @@ jobs: - name: Cache Composer packages id: composer-cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: vendor key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} @@ -40,10 +38,11 @@ jobs: - name: Install dependencies if: steps.composer-cache.outputs.cache-hit != 'true' - run: composer install --prefer-dist --no-progress --no-suggest + run: composer install --prefer-dist --no-progress - name: Run test suite run: | mkdir -p build/logs + composer run rector:dry composer run stan composer run test diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dbd33b0..028b135 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,7 +8,12 @@ jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' - name: Build docs run: | diff --git a/Dockerfile b/Dockerfile index 26c01c3..1a1d0be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use the official PHP 8 image as the base image -FROM php:8.1-fpm +FROM php:8.3-fpm # Set the working directory WORKDIR /var/www/html @@ -21,15 +21,17 @@ RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip RUN pecl install xdebug \ && docker-php-ext-enable xdebug +# Write xdebug settings to a separate ini file so the `zend_extension=` line +# generated by docker-php-ext-enable above is preserved (not overwritten). RUN { \ echo "xdebug.mode=coverage"; \ echo "xdebug.start_with_request=yes"; \ echo "xdebug.client_host=host.docker.internal"; \ echo "xdebug.client_port=9000"; \ - } > /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini; \ + } > /usr/local/etc/php/conf.d/xdebug-settings.ini # Install Composer (a PHP package manager) -RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer # Mount a local folder to /var/www/html VOLUME ./:/var/www/html diff --git a/composer.json b/composer.json index 8294d8b..b7360d9 100644 --- a/composer.json +++ b/composer.json @@ -17,13 +17,15 @@ } ], "require": { - "php": "^8.0|^8.1|^8.2", + "php": "^8.3", "psr/container": "^2.0", "psr/event-dispatcher": "^1.0" }, "require-dev": { - "phpunit/phpunit": "^9.6", - "phpstan/phpstan": "^1.10" + "phpunit/phpunit": "^12.0", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.0", + "friendsofphp/php-cs-fixer": "^3.34" }, "autoload": { "files": [ @@ -41,10 +43,16 @@ "php vendor/bin/phpstan analyse" ], "csfix": [ - "tools/php-cs-fixer/vendor/bin/php-cs-fixer fix --standard=PSR-2 src" + "php vendor/bin/php-cs-fixer fix --rules=@PSR12 src" ], "test": [ "php vendor/bin/phpunit -c tests/phpunit.xml" + ], + "rector": [ + "php vendor/bin/rector process" + ], + "rector:dry": [ + "php vendor/bin/rector process --dry-run" ] } } diff --git a/docker-compose.yml b/docker-compose.yml index 9525954..a36db68 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,10 @@ -version: '3' - services: ### PHP ################################################### php: build: context: ./ - container_name: stack_runner_php + container_name: siriusphp_invokator volumes: - ./:/var/www/html:delegated ports: diff --git a/phpstan.neon b/phpstan.neon index 783dcad..c158993 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,4 @@ parameters: level: 9 - checkGenericClassInNonGenericObjectType: false paths: - src diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..6aae3b7 --- /dev/null +++ b/rector.php @@ -0,0 +1,33 @@ +withPaths([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + // Resolve the PHP feature level from composer.json ("php": "^8.3"), + // so emitted syntax always runs on the lowest supported version. + ->withPhpSets() + ->withPreparedSets( + deadCode: true, + codeQuality: true, + typeDeclarations: true, + earlyReturn: true, + ) + // `readonly` on public value-object properties is a minor BC break, so it is + // applied deliberately by hand (see the value objects in src/) rather than + // swept in automatically. + ->withSkip([ + ReadOnlyPropertyRector::class, + // $reversed must keep its class-level default for __unserialize() (which + // bypasses the constructor), so it cannot be a promoted property. + ClassPropertyAssignToConstructorPromotionRector::class => [ + __DIR__ . '/src/CallableCollection.php', + ], + ]); diff --git a/src/ArgumentReference.php b/src/ArgumentReference.php index 1cf6b5e..c8c442b 100644 --- a/src/ArgumentReference.php +++ b/src/ArgumentReference.php @@ -6,7 +6,7 @@ class ArgumentReference { - public function __construct(public int $reference) + public function __construct(public readonly int $reference) { } } diff --git a/src/CallableCollection.php b/src/CallableCollection.php index 95cd492..1642ee0 100644 --- a/src/CallableCollection.php +++ b/src/CallableCollection.php @@ -4,10 +4,15 @@ namespace Sirius\Invokator; +/** + * @extends \SplPriorityQueue, mixed> + */ class CallableCollection extends \SplPriorityQueue { protected int $index = 0; + // NOT promoted on purpose: __unserialize() bypasses the constructor, so the + // property needs a class-level default to stay initialized after unserializing. protected bool $reversed = false; public function __construct(bool $reversed = false) @@ -58,8 +63,6 @@ public function __serialize(): array /** * @param array $data - * - * @return void */ public function __unserialize(array $data): void { diff --git a/src/Event/Dispatcher.php b/src/Event/Dispatcher.php index a9b20da..3a7a195 100644 --- a/src/Event/Dispatcher.php +++ b/src/Event/Dispatcher.php @@ -12,7 +12,6 @@ class Dispatcher implements EventDispatcherInterface { - public function __construct(public ListenerProviderInterface $registry, public Invoker $invoker) { } @@ -36,9 +35,10 @@ public function dispatch(object $event): object public function subscribeTo(string $eventName, mixed $callable, int $priority = 0): void { - if ( ! $this->registry instanceof ListenerSubscriber) { - throw new \LogicException(sprintf('Unable to subscribe listener because %s is not instace of %s', - get_class($this->registry), + if (! $this->registry instanceof ListenerSubscriber) { + throw new \LogicException(sprintf( + 'Unable to subscribe listener because %s is not instace of %s', + $this->registry::class, ListenerSubscriber::class )); } @@ -48,9 +48,10 @@ public function subscribeTo(string $eventName, mixed $callable, int $priority = public function subscribeOnceTo(string $eventName, mixed $callable, int $priority = 0): void { - if ( ! $this->registry instanceof ListenerSubscriber) { - throw new \LogicException(sprintf('Unable to subscribe listener because %s is not instace of %s', - get_class($this->registry), + if (! $this->registry instanceof ListenerSubscriber) { + throw new \LogicException(sprintf( + 'Unable to subscribe listener because %s is not instace of %s', + $this->registry::class, ListenerSubscriber::class )); } diff --git a/src/Event/ListenerProvider.php b/src/Event/ListenerProvider.php index 9cbce0e..129c660 100644 --- a/src/Event/ListenerProvider.php +++ b/src/Event/ListenerProvider.php @@ -6,6 +6,7 @@ use Psr\EventDispatcher\ListenerProviderInterface; use Sirius\Invokator\CallableCollection; + use function Sirius\Invokator\once; class ListenerProvider implements ListenerProviderInterface, ListenerSubscriber @@ -14,13 +15,9 @@ class ListenerProvider implements ListenerProviderInterface, ListenerSubscriber * @var array */ protected array $registry = []; // @phpstan-ignore-line - - /** - * @return iterable|CallableCollection - */ public function getListenersForEvent(object $event): iterable // @phpstan-ignore-line { - $eventName = get_class($event); + $eventName = $event::class; if ($event instanceof HasEventName) { $eventName = $event->getEventName(); } diff --git a/src/Event/Stoppable.php b/src/Event/Stoppable.php index d81d6c1..c16f107 100644 --- a/src/Event/Stoppable.php +++ b/src/Event/Stoppable.php @@ -4,6 +4,12 @@ namespace Sirius\Invokator\Event; +/** + * Public-API convenience trait for consumers' stoppable events; it is used outside + * the analysed `src/` paths (e.g. by event classes in applications using this library). + * + * @phpstan-ignore trait.unused + */ trait Stoppable { protected bool $propagationStopped = false; diff --git a/src/InvalidCallableException.php b/src/InvalidCallableException.php index dd77bea..2bb4254 100644 --- a/src/InvalidCallableException.php +++ b/src/InvalidCallableException.php @@ -1,5 +1,7 @@ container = $container; } /** @@ -109,7 +106,7 @@ protected function resolveArguments(mixed $callable, array $args): array } else { throw new InvalidArgumentException("Cannot resolve parameter: $paramName"); } - } else if (isset($args[$paramName]) || $param->getDefaultValue()) { + } elseif (isset($args[$paramName]) || $param->getDefaultValue()) { // Builtin types, such as int or string, do not need resolution. $resolvedArgs[] = $args[$paramName] ?? $param->getDefaultValue(); } else { diff --git a/src/InvokerReference.php b/src/InvokerReference.php index 9bba466..3670ae7 100644 --- a/src/InvokerReference.php +++ b/src/InvokerReference.php @@ -6,7 +6,7 @@ class InvokerReference { - public function __construct(public string $reference) + public function __construct(public readonly string $reference) { } } diff --git a/src/InvokerResult.php b/src/InvokerResult.php index b1c3194..23c6c6b 100644 --- a/src/InvokerResult.php +++ b/src/InvokerResult.php @@ -7,10 +7,9 @@ class InvokerResult { /** - * @param mixed $callable * @param array $params */ - public function __construct(public mixed $callable, public array $params = []) + public function __construct(public readonly mixed $callable, public readonly array $params = []) { } } diff --git a/src/Modifiers/LimitArguments.php b/src/Modifiers/LimitArguments.php index 8b71cfd..4c24e80 100644 --- a/src/Modifiers/LimitArguments.php +++ b/src/Modifiers/LimitArguments.php @@ -11,7 +11,7 @@ class LimitArguments implements InvokerAwareInterface { protected Invoker $invoker; - public function __construct(public mixed $callable, public int $argumentsLimit) + public function __construct(public readonly mixed $callable, public readonly int $argumentsLimit) { } diff --git a/src/Modifiers/Once.php b/src/Modifiers/Once.php index 02282c5..37a3f6c 100644 --- a/src/Modifiers/Once.php +++ b/src/Modifiers/Once.php @@ -13,9 +13,9 @@ class Once implements InvokerAwareInterface protected mixed $result = null; - protected bool $has_run = false; + protected bool $hasRun = false; - public function __construct(public mixed $callable) + public function __construct(public readonly mixed $callable) { } @@ -29,12 +29,12 @@ public function setInvoker(Invoker $invoker): void */ public function __invoke(...$params): mixed { - if ($this->has_run) { + if ($this->hasRun) { return $this->result; } $this->result = $this->invoker->invoke($this->callable, ...$params); - $this->has_run = true; + $this->hasRun = true; return $this->result; } diff --git a/src/Modifiers/ResolveArguments.php b/src/Modifiers/ResolveArguments.php index cb96339..83c56f3 100644 --- a/src/Modifiers/ResolveArguments.php +++ b/src/Modifiers/ResolveArguments.php @@ -16,7 +16,9 @@ class ResolveArguments implements InvokerAwareInterface /** * @param array $arguments */ - public function __construct(public mixed $callable, public array $arguments) + // $arguments is intentionally NOT readonly: __invoke() resolves ArgumentReferences + // into it in place. + public function __construct(public readonly mixed $callable, public array $arguments) { } @@ -28,7 +30,6 @@ public function setInvoker(Invoker $invoker): void /** * @param array $params * - * @return mixed * @throws \Psr\Container\ContainerExceptionInterface * @throws \Psr\Container\NotFoundExceptionInterface */ diff --git a/src/Modifiers/WithArguments.php b/src/Modifiers/WithArguments.php index f7023bd..d2e904d 100644 --- a/src/Modifiers/WithArguments.php +++ b/src/Modifiers/WithArguments.php @@ -16,7 +16,7 @@ class WithArguments implements InvokerAwareInterface /** * @param array $arguments */ - public function __construct(public mixed $callable, public array $arguments) + public function __construct(public readonly mixed $callable, public readonly array $arguments) { } diff --git a/src/Modifiers/Wrap.php b/src/Modifiers/Wrap.php index 7aed24d..f25b0e9 100644 --- a/src/Modifiers/Wrap.php +++ b/src/Modifiers/Wrap.php @@ -12,10 +12,9 @@ class Wrap implements InvokerAwareInterface protected Invoker $invoker; /** - * @param mixed $callable * @param callable $wrapperCallback */ - public function __construct(public mixed $callable, public $wrapperCallback) + public function __construct(public readonly mixed $callable, public readonly mixed $wrapperCallback) { } @@ -29,7 +28,7 @@ public function setInvoker(Invoker $invoker): void */ public function __invoke(...$params): mixed { - $next = fn () => $this->invoker->invoke($this->callable, ...$params); + $next = fn (): mixed => $this->invoker->invoke($this->callable, ...$params); return call_user_func($this->wrapperCallback, $next); } diff --git a/src/PipelinePromise.php b/src/PipelinePromise.php index 3b62e02..5c2a4d3 100644 --- a/src/PipelinePromise.php +++ b/src/PipelinePromise.php @@ -9,7 +9,7 @@ class PipelinePromise /** * @param array $params */ - public function __construct(public mixed $value, public CallableCollection $remainingCallables, public array $params, public int $retryAfter) + public function __construct(public readonly mixed $value, public readonly CallableCollection $remainingCallables, public readonly array $params, public readonly int $retryAfter) { } } diff --git a/src/Processors/ActionsProcessor.php b/src/Processors/ActionsProcessor.php index fb976d8..e642183 100644 --- a/src/Processors/ActionsProcessor.php +++ b/src/Processors/ActionsProcessor.php @@ -16,7 +16,7 @@ class ActionsProcessor implements CallablesRegistryInterface, InvokatorInterface /** * @var array */ - protected $registry = []; + protected array $registry = []; public function __construct(public Invoker $invoker) { diff --git a/src/Processors/CommandBus.php b/src/Processors/CommandBus.php index d79c332..6b22209 100644 --- a/src/Processors/CommandBus.php +++ b/src/Processors/CommandBus.php @@ -30,7 +30,7 @@ public function addMiddleware(string $name, mixed $callable, int $priority = 0): public function handle(object $command): mixed { - $callableCollection = $this->getCopy(get_class($command)); + $callableCollection = $this->getCopy($command::class); $callableCollection->add($this->getCallableForCommand($command), PHP_INT_MIN); // to be executed at the end $result = null; @@ -39,7 +39,7 @@ public function handle(object $command): mixed if ($callableCollection->isEmpty()) { $response = $this->invoker->invoke($nextCallable, $command); } else { - $next = fn($result) => $this->processCollection($callableCollection, $command); + $next = fn ($result): mixed => $this->processCollection($callableCollection, $command); $response = $this->invoker->invoke($nextCallable, $command, $next); } @@ -51,6 +51,7 @@ public function handle(object $command): mixed return $result; } + #[\Override] public function process(string $name, ...$params): mixed { throw new \BadMethodCallException('You should not call the process() method on the command bus. Use handle() instead!'); @@ -58,11 +59,11 @@ public function process(string $name, ...$params): mixed protected function getCallableForCommand(object $commandInstance): mixed { - $commandClass = get_class($commandInstance); + $commandClass = $commandInstance::class; $handler = $this->handlers[$commandClass] ?? preg_replace('/(.+)Command$/', '$1Handler', $commandClass); if (is_string($handler)) { - if ( ! class_exists($handler)) { + if (! class_exists($handler)) { // maybe the handler is something like `SomeClass::method` or `SomeClass@method` return $handler; } diff --git a/src/Processors/FiltersProcessor.php b/src/Processors/FiltersProcessor.php index 2968607..1400ce2 100644 --- a/src/Processors/FiltersProcessor.php +++ b/src/Processors/FiltersProcessor.php @@ -16,7 +16,7 @@ class FiltersProcessor implements CallablesRegistryInterface, InvokatorInterface /** * @var array */ - protected $registry = []; + protected array $registry = []; public function __construct(public Invoker $invoker) { diff --git a/src/Processors/MiddlewareProcessor.php b/src/Processors/MiddlewareProcessor.php index d1d4ab2..8f3f9e6 100644 --- a/src/Processors/MiddlewareProcessor.php +++ b/src/Processors/MiddlewareProcessor.php @@ -16,6 +16,7 @@ class MiddlewareProcessor extends SimpleCallablesProcessor * @throws \Psr\Container\NotFoundExceptionInterface * @throws InvalidCallableException */ + #[\Override] public function processCollection(CallableCollection $stack, ...$params): mixed { $result = null; @@ -25,7 +26,7 @@ public function processCollection(CallableCollection $stack, ...$params): mixed if ($stack->isEmpty()) { $response = $this->invoker->invoke($nextCallable, ...$params); } else { - $next = fn ($result) => $this->processCollection($stack, ...$params); + $next = fn ($result): mixed => $this->processCollection($stack, ...$params); $paramsForNext = [...$params, $next]; $response = $this->invoker->invoke($nextCallable, ...$paramsForNext); } diff --git a/src/Processors/PipelineProcessor.php b/src/Processors/PipelineProcessor.php index 495621c..3fc15be 100644 --- a/src/Processors/PipelineProcessor.php +++ b/src/Processors/PipelineProcessor.php @@ -15,6 +15,7 @@ class PipelineProcessor extends SimpleCallablesProcessor * @param array $params */ + #[\Override] public function processCollection(CallableCollection $stack, ...$params): mixed { $result = null; diff --git a/src/Processors/SimpleCallablesProcessor.php b/src/Processors/SimpleCallablesProcessor.php index 54929d8..e938ed5 100644 --- a/src/Processors/SimpleCallablesProcessor.php +++ b/src/Processors/SimpleCallablesProcessor.php @@ -14,7 +14,7 @@ class SimpleCallablesProcessor implements CallablesRegistryInterface, InvokatorI /** * @var array */ - protected $registry = []; + protected array $registry = []; public function __construct(public Invoker $invoker) { diff --git a/src/SuggestedResume.php b/src/SuggestedResume.php index a46e4a5..ffcbb03 100644 --- a/src/SuggestedResume.php +++ b/src/SuggestedResume.php @@ -6,7 +6,7 @@ class SuggestedResume { - public function __construct(public mixed $value, public int $delay = 0) + public function __construct(public readonly mixed $value, public readonly int $delay = 0) { } } diff --git a/src/SuggestedRetry.php b/src/SuggestedRetry.php index 55402ad..034d6ba 100644 --- a/src/SuggestedRetry.php +++ b/src/SuggestedRetry.php @@ -6,7 +6,7 @@ class SuggestedRetry { - public function __construct(public int $retryAfter = 0) + public function __construct(public readonly int $retryAfter = 0) { } } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index ebdda95..65923ab 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -2,31 +2,27 @@ - - - ./../src - + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.0/phpunit.xsd"> + - ./src/ + + + ./../src + + diff --git a/tests/phpunit_bootstrap.php b/tests/phpunit_bootstrap.php index 4280de8..74f71da 100644 --- a/tests/phpunit_bootstrap.php +++ b/tests/phpunit_bootstrap.php @@ -1,5 +1,7 @@ first + $command->second; } diff --git a/tests/src/Event/DispatcherTest.php b/tests/src/Event/DispatcherTest.php index a5c326b..b0f2865 100644 --- a/tests/src/Event/DispatcherTest.php +++ b/tests/src/Event/DispatcherTest.php @@ -10,16 +10,16 @@ class DispatcherTest extends TestCase { - public function test_subscribers_are_executed_in_order() + public function test_subscribers_are_executed_in_order(): void { $dispatcher = new Dispatcher(new ListenerProvider(), $this->getInvoker()); - $dispatcher->subscribeTo('event_with_name', function (object $event) { + $dispatcher->subscribeTo('event_with_name', function (object $event): void { static::$results[] = 'subscriber 1'; }); - $dispatcher->subscribeTo('event_with_name', function (object $event) { + $dispatcher->subscribeTo('event_with_name', function (object $event): void { static::$results[] = 'subscriber 2'; }); - $dispatcher->subscribeTo('event_with_name', function (object $event) { + $dispatcher->subscribeTo('event_with_name', function (object $event): void { static::$results[] = 'subscriber 3'; }); @@ -32,17 +32,17 @@ public function test_subscribers_are_executed_in_order() ], static::$results); } - public function test_stoppable_events() + public function test_stoppable_events(): void { $dispatcher = new Dispatcher(new ListenerProvider(), $this->getInvoker()); - $dispatcher->subscribeTo(StoppableEvent::class, function (object $event) { + $dispatcher->subscribeTo(StoppableEvent::class, function (object $event): void { static::$results[] = 'subscriber 1'; }); - $dispatcher->subscribeTo(StoppableEvent::class, function (object $event) { + $dispatcher->subscribeTo(StoppableEvent::class, function (object $event): void { static::$results[] = 'subscriber 2'; $event->stopPropagation(); }); - $dispatcher->subscribeTo(StoppableEvent::class, function (object $event) { + $dispatcher->subscribeTo(StoppableEvent::class, function (object $event): void { static::$results[] = 'subscriber 3'; }); @@ -54,10 +54,10 @@ public function test_stoppable_events() ], static::$results); } - public function test_once_subscribers() + public function test_once_subscribers(): void { $dispatcher = new Dispatcher(new ListenerProvider(), $this->getInvoker()); - $dispatcher->subscribeOnceTo(EventWithoutName::class, function (object $event) { + $dispatcher->subscribeOnceTo(EventWithoutName::class, function (object $event): void { static::$results[] = 'once subscriber'; }); diff --git a/tests/src/Event/EventWithName.php b/tests/src/Event/EventWithName.php index e2f5dbe..85a7613 100644 --- a/tests/src/Event/EventWithName.php +++ b/tests/src/Event/EventWithName.php @@ -1,5 +1,7 @@ getInvoker()); - $processor->add('test', once(function ($param_1, $param_2) { + $processor->add('test', once(function (string $param_1, $param_2): void { static::$results[] = sprintf("anonymous function(%s, %s)", $param_1, $param_2); })); diff --git a/tests/src/Modifiers/ResolveArgumentsTest.php b/tests/src/Modifiers/ResolveArgumentsTest.php index 8d6f698..94da202 100644 --- a/tests/src/Modifiers/ResolveArgumentsTest.php +++ b/tests/src/Modifiers/ResolveArgumentsTest.php @@ -26,11 +26,11 @@ protected function setUp(): void $this->getContainer()->register(DependencyClass::class, new DependencyClass()); } - public function test_resolve_arguments() + public function test_resolve_arguments(): void { $this->getContainer()->register('test_param', 'C'); $processor = new SimpleCallablesProcessor($this->getInvoker()); - $processor->add('test', wrap(resolve(DependentClass::class . '@multiply', ['firstNumber' => 5, 'secondNumber' => arg(0)]), function($next){ + $processor->add('test', wrap(resolve(DependentClass::class . '@multiply', ['firstNumber' => 5, 'secondNumber' => arg(0)]), function($next): void{ static::$results[] = $next(); })); diff --git a/tests/src/Modifiers/WithArgumentsTest.php b/tests/src/Modifiers/WithArgumentsTest.php index 8188cf7..cf0dc3f 100644 --- a/tests/src/Modifiers/WithArgumentsTest.php +++ b/tests/src/Modifiers/WithArgumentsTest.php @@ -19,11 +19,11 @@ protected function setUp(): void static::$results = []; } - public function test_modifier_with_refs() + public function test_modifier_with_refs(): void { $this->getContainer()->register('test_param', 'C'); $processor = new SimpleCallablesProcessor($this->getInvoker()); - $processor->add('test', with_arguments(function ($param_1, $param_2, $param_3, $param_4) { + $processor->add('test', with_arguments(function (string $param_1, $param_2, $param_3, $param_4): void { static::$results[] = sprintf("anonymous function(%s, %s, %s, %s)", $param_1, $param_2, $param_3, $param_4); }, [arg(1), arg(0), ref('test_param'), 'D'])); @@ -34,11 +34,11 @@ public function test_modifier_with_refs() ], static::$results); } - public function test_modifier_with_invoker_result() + public function test_modifier_with_invoker_result(): void { $this->getContainer()->register('test_param', 'C'); $processor = new SimpleCallablesProcessor($this->getInvoker()); - $processor->add('test', with_arguments(function ($param_1, $param_2, $param_3) { + $processor->add('test', with_arguments(function (string $param_1, $param_2, $param_3): void { static::$results[] = sprintf("anonymous function(%s, %s, %s)", $param_1, $param_2, $param_3); }, [result_of('trim', [' C ']), arg(1), arg(0)])); diff --git a/tests/src/Modifiers/WrapTest.php b/tests/src/Modifiers/WrapTest.php index 11e2de9..bf18c13 100644 --- a/tests/src/Modifiers/WrapTest.php +++ b/tests/src/Modifiers/WrapTest.php @@ -15,11 +15,11 @@ protected function setUp(): void static::$results = []; } - public function test_modifier() + public function test_modifier(): void { $this->getContainer()->register(SimpleCallables::class, new SimpleCallables); $processor = new SimpleCallablesProcessor($this->getInvoker()); - $processor->add('test', wrap(function ($param_1, $param_2) { + $processor->add('test', wrap(function (string $param_1, $param_2): void { static::$results[] = sprintf("anonymous function(%s, %s)", $param_1, $param_2); }, function ($next) { static::$results[] = 'From wrapper function'; diff --git a/tests/src/Processors/ActionsProcessorTest.php b/tests/src/Processors/ActionsProcessorTest.php index 352b581..047c9d3 100644 --- a/tests/src/Processors/ActionsProcessorTest.php +++ b/tests/src/Processors/ActionsProcessorTest.php @@ -7,11 +7,11 @@ class ActionsProcessorTest extends TestCase { - public function test_actions_processor() + public function test_actions_processor(): void { $this->getContainer()->register(SimpleCallables::class, new SimpleCallables); $processor = new ActionsProcessor($this->getInvoker()); - $processor->add('test', function ($param_1) { + $processor->add('test', function (string $param_1): void { static::$results[] = sprintf("anonymous function(%s)", $param_1, 1); }, 0, 1); $processor->add('test', SimpleCallables::class . '::staticMethod', 0, 1); diff --git a/tests/src/Processors/CommandBusTest.php b/tests/src/Processors/CommandBusTest.php index 68118b2..0a2dceb 100644 --- a/tests/src/Processors/CommandBusTest.php +++ b/tests/src/Processors/CommandBusTest.php @@ -8,35 +8,29 @@ class CommandBusTest extends TestCase { - public function test_automatic_handler() + public function test_automatic_handler(): void { $this->getContainer()->register(SampleHandler::class, new SampleHandler()); $bus = new CommandBus($this->getInvoker()); - $bus->addMiddleware(SampleCommand::class, function ($name, $next = null) { - return 2 * $next($name); - }); + $bus->addMiddleware(SampleCommand::class, fn($name, $next = null): int|float => 2 * $next($name)); $result = $bus->handle(new SampleCommand(2, 5)); $this->assertEquals(2 * (2 + 5), $result); } - public function test_custom_handler() + public function test_custom_handler(): void { $bus = new CommandBus($this->getInvoker()); - $bus->addMiddleware(SampleCommand::class, function ($name, $next = null) { - return 2 * $next($name); - }); - $bus->register(SampleCommand::class, function (SampleCommand $command) { - return $command->first * $command->second; - }); + $bus->addMiddleware(SampleCommand::class, fn($name, $next = null): int|float => 2 * $next($name)); + $bus->register(SampleCommand::class, fn(SampleCommand $command): int => $command->first * $command->second); $result = $bus->handle(new SampleCommand(2, 5)); $this->assertEquals(2 * 2 * 5, $result); } - public function test_no_middleware() + public function test_no_middleware(): void { $this->getContainer()->register(SampleHandler::class, new SampleHandler()); $bus = new CommandBus($this->getInvoker()); diff --git a/tests/src/Processors/FiltersProcessorTest.php b/tests/src/Processors/FiltersProcessorTest.php index 7b88b94..bb36fd0 100644 --- a/tests/src/Processors/FiltersProcessorTest.php +++ b/tests/src/Processors/FiltersProcessorTest.php @@ -7,13 +7,11 @@ class FiltersProcessorTest extends TestCase { - public function test_filters_processor() + public function test_filters_processor(): void { $this->getContainer()->register(SimpleCallables::class, new SimpleCallables); $processor = new FiltersProcessor($this->getInvoker()); - $processor->add('test', function ($name) { - return ' hello ' . $name; - }, 0, 1); + $processor->add('test', fn($name): string => ' hello ' . $name, 0, 1); $processor->add('test', 'trim', 0, 1); $processor->add('test', 'ucwords', 0, 2); diff --git a/tests/src/Processors/MiddlewareProcessorTest.php b/tests/src/Processors/MiddlewareProcessorTest.php index 6ad4580..3eeb1c3 100644 --- a/tests/src/Processors/MiddlewareProcessorTest.php +++ b/tests/src/Processors/MiddlewareProcessorTest.php @@ -7,19 +7,13 @@ class MiddlewareProcessorTest extends TestCase { - public function test_middleware_processor() + public function test_middleware_processor(): void { $this->getContainer()->register(SimpleCallables::class, new SimpleCallables); $processor = new MiddlewareProcessor($this->getInvoker()); - $processor->add('test', function ($name, $next = null) { - return ucwords($next($name)); - }); - $processor->add('test', function ($name, $next = null) { - return 'Hello ' . $next($name); - }); - $processor->add('test', function ($name, $next = null) { - return $name; - }); + $processor->add('test', fn($name, $next = null): string => ucwords((string) $next($name))); + $processor->add('test', fn($name, $next = null): string => 'Hello ' . $next($name)); + $processor->add('test', fn($name, $next = null) => $name); $result = $processor->process('test', 'world'); diff --git a/tests/src/Processors/PipelineProcessorTest.php b/tests/src/Processors/PipelineProcessorTest.php index 6efb5c2..5071f2b 100644 --- a/tests/src/Processors/PipelineProcessorTest.php +++ b/tests/src/Processors/PipelineProcessorTest.php @@ -7,13 +7,11 @@ class PipelineProcessorTest extends TestCase { - public function test_pipeline_processor() + public function test_pipeline_processor(): void { $this->getContainer()->register(SimpleCallables::class, new SimpleCallables); $processor = new PipelineProcessor($this->getInvoker()); - $processor->add('test', function ($name) { - return ' hello ' . $name; - }, 1, 1); + $processor->add('test', fn($name): string => ' hello ' . $name, 1); $processor->add('test', 'trim', 1); $processor->add('test', 'ucwords', 1); diff --git a/tests/src/Processors/SimpleStackProcessorTest.php b/tests/src/Processors/SimpleStackProcessorTest.php index 1b9f6b3..bcf31a1 100644 --- a/tests/src/Processors/SimpleStackProcessorTest.php +++ b/tests/src/Processors/SimpleStackProcessorTest.php @@ -7,11 +7,11 @@ class SimpleStackProcessorTest extends TestCase { - public function test_simple_stack_processor() + public function test_simple_stack_processor(): void { $this->getContainer()->register(SimpleCallables::class, new SimpleCallables); $processor = new SimpleCallablesProcessor($this->getInvoker()); - $processor->add('test', function ($param_1, $param_2) { + $processor->add('test', function (string $param_1, $param_2): void { static::$results[] = sprintf("anonymous function(%s, %s)", $param_1, $param_2); }); $processor->add('test', SimpleCallables::class . '::staticMethod'); @@ -26,11 +26,11 @@ public function test_simple_stack_processor() ], static::$results); } - public function test_execution_priority() + public function test_execution_priority(): void { $this->getContainer()->register(SimpleCallables::class, new SimpleCallables); $processor = new SimpleCallablesProcessor($this->getInvoker()); - $processor->add('test', function ($param_1, $param_2) { + $processor->add('test', function (string $param_1, $param_2): void { static::$results[] = sprintf("anonymous function(%s, %s)", $param_1, $param_2); }); $processor->add('test', SimpleCallables::class . '@method', 100); diff --git a/tests/src/StackTest.php b/tests/src/StackTest.php index 39d72b1..fb3157b 100644 --- a/tests/src/StackTest.php +++ b/tests/src/StackTest.php @@ -4,7 +4,7 @@ class StackTest extends TestCase { - function test_priorities_are_respected() + function test_priorities_are_respected(): void { $stack = new CallableCollection(); $stack->add('callable_1', 10); @@ -24,7 +24,7 @@ function test_priorities_are_respected() ], $this->getCallablesFromStack($stack)); } - public function test_serialization_of_simple_stack() + public function test_serialization_of_simple_stack(): void { $stack = new CallableCollection(); $stack->add('callable_1', 10); diff --git a/tests/src/TestCase.php b/tests/src/TestCase.php index 1257179..b9e297a 100644 --- a/tests/src/TestCase.php +++ b/tests/src/TestCase.php @@ -24,7 +24,7 @@ public function has(string $id): bool return isset($this->services[$id]); } - public function register(string $id, $implementation) + public function register(string $id, $implementation): void { $this->services[$id] = $implementation; } @@ -50,12 +50,15 @@ protected function setUp(): void static::$results = []; } - protected function getInvoker() + protected function getInvoker(): \Sirius\Invokator\Invoker { return new Invoker($this->getContainer()); } - protected function getCallablesFromStack(CallableCollection $stack) + /** + * @return mixed[] + */ + protected function getCallablesFromStack(CallableCollection $stack): array { $callables = []; do { @@ -68,7 +71,7 @@ protected function getCallablesFromStack(CallableCollection $stack) return $callables; } - protected function getContainer() + protected function getContainer(): \Sirius\Invokator\Container { if ( ! isset($this->container)) { $this->container = new Container(); diff --git a/tests/src/Utilities/DependencyClass.php b/tests/src/Utilities/DependencyClass.php index 7031311..a1b0ec2 100644 --- a/tests/src/Utilities/DependencyClass.php +++ b/tests/src/Utilities/DependencyClass.php @@ -1,5 +1,7 @@ add5($secondNumber); } diff --git a/tests/src/Utilities/SimpleCallables.php b/tests/src/Utilities/SimpleCallables.php index 48e69d4..880bb0a 100644 --- a/tests/src/Utilities/SimpleCallables.php +++ b/tests/src/Utilities/SimpleCallables.php @@ -1,5 +1,7 @@ Date: Sat, 6 Jun 2026 19:01:48 +0300 Subject: [PATCH 2/7] Add "Upgrading to 2.0" documentation page Document the consumer-facing changes for 2.0 (PHP 8.3+ requirement and the readonly value objects) and wire it into the couscous menu. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/9_upgrading_to_2.md | 60 ++++++++++++++++++++++++++++++++++++++++ docs/couscous.yml | 6 ++++ 2 files changed, 66 insertions(+) create mode 100644 docs/9_upgrading_to_2.md diff --git a/docs/9_upgrading_to_2.md b/docs/9_upgrading_to_2.md new file mode 100644 index 0000000..534690d --- /dev/null +++ b/docs/9_upgrading_to_2.md @@ -0,0 +1,60 @@ +--- +title: Upgrading Sirius\Invokator from 1.x to 2.0 +--- + +# Upgrading to 2.0 + +Version 2.0 modernizes the library for current PHP versions and refreshes the +development tooling. The public API is unchanged, so for most applications the +upgrade is just a version bump. There are two things to be aware of. + +## 1. PHP 8.3 or newer is required + +The minimum supported PHP version moved from 8.0 to **8.3**. The library is tested +against PHP 8.3, 8.4 and 8.5. + +If you are still on PHP 8.0–8.2, stay on the `1.x` releases until you can upgrade +your runtime. + +```bash +composer require siriusphp/invokator:^2.0 +``` + +## 2. Value objects are now immutable (`readonly`) + +The small value objects used internally now declare their public properties as +`readonly`: + +- `ArgumentReference` — created by `arg()` +- `InvokerReference` — created by `ref()` +- `InvokerResult` — created by `result_of()` +- `PipelinePromise` +- `SuggestedResume` +- `SuggestedRetry` + +In practice you create these through the helper functions and read their +properties, so nothing changes. The only breaking case is code that **reassigned** +one of their public properties after construction: + +```php +$ref = arg(0); +$ref->reference = 1; // 2.0: Error — cannot modify readonly property +``` + +Build a new instance instead of mutating an existing one: + +```php +$ref = arg(1); +``` + +## Tooling changes (development only) + +These do not affect applications consuming the library; they matter only if you +work on the library itself or copy its setup: + +- PHPUnit `9` → `12` (test configuration migrated to the 12.x schema). +- PHPStan `1` → `2` (still analysed at level 9). +- [Rector](https://getrector.com/) `2` added; run `composer run rector`. +- `friendsofphp/php-cs-fixer` moved from `tools/` into `require-dev`; `composer run + csfix` now applies the `@PSR12` ruleset. +- The bundled Docker image is based on `php:8.3-fpm`. diff --git a/docs/couscous.yml b/docs/couscous.yml index 92091bc..8fbdc84 100644 --- a/docs/couscous.yml +++ b/docs/couscous.yml @@ -80,3 +80,9 @@ menu: auto_middleware: text: Automatic middleware relativeUrl: 6_automatic_middleware.html + upgrading: + name: Upgrading + items: + upgrade_2_0: + text: Upgrading to 2.0 + relativeUrl: 9_upgrading_to_2.html From ae7de578dc78a75e0bcb40e672eb3273f9ef8727 Mon Sep 17 00:00:00 2001 From: adrianmiu Date: Sat, 6 Jun 2026 19:01:56 +0300 Subject: [PATCH 3/7] Support plain function-name string callables with strict PSR-11 containers Invoker::getActualCallable() looked up every callable-looking string in the container without first checking has(). A spec-compliant PSR-11 container (such as Laravel's) throws NotFoundException when get() is called for an unbound id, so a plain function name like 'trim' or a 'Class::method' string would blow up instead of being used directly. Guard the container lookup with has() so unbound callable strings fall through and are used as-is, while bound callable names still resolve from the container. This is behaviour-preserving for the lenient test container (has() already returns false for unbound ids) and makes the lookup PSR-11 correct. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Invoker.php | 10 ++- tests/src/InvokerStrictContainerTest.php | 79 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 tests/src/InvokerStrictContainerTest.php diff --git a/src/Invoker.php b/src/Invoker.php index 2009971..b682953 100644 --- a/src/Invoker.php +++ b/src/Invoker.php @@ -118,8 +118,8 @@ protected function resolveArguments(mixed $callable, array $args): array } /** - * @throws \Psr\Container\ContainerExceptionInterface - * @throws \Psr\Container\NotFoundExceptionInterface + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface * @throws InvalidCallableException */ protected function getActualCallable(mixed $callable): callable @@ -133,9 +133,13 @@ protected function getActualCallable(mixed $callable): callable $callable = [$service, $method]; } - // if the callable references an invokable class from the container + // A string callable may also reference an invokable service bound in the container. + // Guard the lookup with has() so a plain function name like 'trim' — for which a + // strict PSR-11 container's get() throws NotFoundException — falls through and is + // used as the callable directly instead. if (is_string($callable) && is_callable($callable) && + $this->container->has($callable) && ($service = $this->container->get($callable)) && is_callable($service) ) { diff --git a/tests/src/InvokerStrictContainerTest.php b/tests/src/InvokerStrictContainerTest.php new file mode 100644 index 0000000..3abc187 --- /dev/null +++ b/tests/src/InvokerStrictContainerTest.php @@ -0,0 +1,79 @@ + */ + private array $services = []; + + public function set(string $id, mixed $value): void + { + $this->services[$id] = $value; + } + + public function get(string $id): mixed + { + if (! $this->has($id)) { + throw new StrictNotFoundException("No entry found for: {$id}"); + } + + return $this->services[$id]; + } + + public function has(string $id): bool + { + return array_key_exists($id, $this->services); + } +} + +class Shouter +{ + public static function shout(string $value): string + { + return strtoupper($value); + } +} + +class InvokerStrictContainerTest extends PHPUnitTestCase +{ + public function test_plain_function_name_string_callable_is_used_directly(): void + { + $invoker = new Invoker(new StrictContainer()); + + // 'trim' is callable but not a bound service: get() would throw, so it must be used as-is. + $this->assertSame('hi', $invoker->invoke('trim', ' hi ')); + } + + public function test_static_method_string_callable_is_used_directly(): void + { + $invoker = new Invoker(new StrictContainer()); + + $this->assertSame('HELLO', $invoker->invoke(Shouter::class . '::shout', 'hello')); + } + + public function test_bound_callable_name_still_resolves_from_the_container(): void + { + $container = new StrictContainer(); + // A callable-looking string that is ALSO bound should resolve to the bound service + // (the has()+get() branch), proving the guard doesn't disable container overrides. + $container->set('strtoupper', fn (string $value): string => 'OVERRIDDEN:' . $value); + $invoker = new Invoker($container); + + $this->assertSame('OVERRIDDEN:x', $invoker->invoke('strtoupper', 'x')); + } +} From 0778e8666f6122954ee13091aab9ec4bee688cb5 Mon Sep 17 00:00:00 2001 From: adrianmiu Date: Sat, 6 Jun 2026 19:02:31 +0300 Subject: [PATCH 4/7] Add Laravel integration (service provider, facade, helpers, Blade) Provide a first-class, optional Laravel bridge under Sirius\Invokator\Laravel (core stays framework-agnostic): - SiriusInvokatorServiceProvider wires Laravel's container into the Invoker as its PSR-11 container and registers the processors and event dispatcher as singletons so registrations persist. - Invokator facade + InvokatorManager: pipeline/action/filter/middleware ('id') return a chainable Registrar to define callables, or run when given args; events use PSR-14 object dispatch (event(Class)->add(), dispatch()). - do_pipeline/do_action/do_filter/do_middleware/do_event global helpers (do_-prefixed to avoid clashing with Laravel's event()/action()), loaded from the provider so non-Laravel users get no global pollution. - @do_action Blade directive. Tested with orchestra/testbench across the CI matrix; the CI cache key now includes the PHP version and composer.json hash so each leg resolves its own dependencies. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 4 +- composer.json | 23 ++++- src/Laravel/Facades/Invokator.php | 26 ++++++ src/Laravel/InvokatorManager.php | 84 +++++++++++++++++++ src/Laravel/Registrar.php | 31 +++++++ .../SiriusInvokatorServiceProvider.php | 52 ++++++++++++ src/Laravel/helpers.php | 57 +++++++++++++ tests/Laravel/BladeTest.php | 35 ++++++++ tests/Laravel/ContainerWiringTest.php | 30 +++++++ tests/Laravel/EventTest.php | 57 +++++++++++++ tests/Laravel/FacadeTest.php | 61 ++++++++++++++ tests/Laravel/Fixtures/Dependency.php | 13 +++ tests/Laravel/Fixtures/Greeter.php | 22 +++++ tests/Laravel/Fixtures/SampleEvent.php | 12 +++ .../Laravel/Fixtures/StoppableSampleEvent.php | 13 +++ tests/Laravel/HelpersTest.php | 48 +++++++++++ tests/Laravel/TestCase.php | 30 +++++++ tests/phpunit.xml | 3 + 18 files changed, 598 insertions(+), 3 deletions(-) create mode 100644 src/Laravel/Facades/Invokator.php create mode 100644 src/Laravel/InvokatorManager.php create mode 100644 src/Laravel/Registrar.php create mode 100644 src/Laravel/SiriusInvokatorServiceProvider.php create mode 100644 src/Laravel/helpers.php create mode 100644 tests/Laravel/BladeTest.php create mode 100644 tests/Laravel/ContainerWiringTest.php create mode 100644 tests/Laravel/EventTest.php create mode 100644 tests/Laravel/FacadeTest.php create mode 100644 tests/Laravel/Fixtures/Dependency.php create mode 100644 tests/Laravel/Fixtures/Greeter.php create mode 100644 tests/Laravel/Fixtures/SampleEvent.php create mode 100644 tests/Laravel/Fixtures/StoppableSampleEvent.php create mode 100644 tests/Laravel/HelpersTest.php create mode 100644 tests/Laravel/TestCase.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65286e0..8d6a1bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,9 @@ jobs: uses: actions/cache@v4 with: path: vendor - key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('composer.json') }} restore-keys: | - ${{ runner.os }}-php- + ${{ runner.os }}-php-${{ matrix.php }}- - name: Install dependencies if: steps.composer-cache.outputs.cache-hit != 'true' diff --git a/composer.json b/composer.json index b7360d9..c75a2be 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,13 @@ "phpunit/phpunit": "^12.0", "phpstan/phpstan": "^2.0", "rector/rector": "^2.0", - "friendsofphp/php-cs-fixer": "^3.34" + "friendsofphp/php-cs-fixer": "^3.34", + "illuminate/support": "^11.0 || ^12.0 || ^13.0", + "illuminate/contracts": "^11.0 || ^12.0 || ^13.0", + "orchestra/testbench": "^10.0 || ^11.0" + }, + "suggest": { + "illuminate/support": "Required to use the Laravel integration (SiriusInvokatorServiceProvider, Invokator facade, do_* helpers, Blade directives)" }, "autoload": { "files": [ @@ -35,9 +41,24 @@ "Sirius\\Invokator\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Sirius\\Invokator\\Tests\\Laravel\\": "tests/Laravel/" + } + }, "provide": { "psr/event-dispatcher-implementation": "1.0" }, + "extra": { + "laravel": { + "providers": [ + "Sirius\\Invokator\\Laravel\\SiriusInvokatorServiceProvider" + ], + "aliases": { + "Invokator": "Sirius\\Invokator\\Laravel\\Facades\\Invokator" + } + } + }, "scripts": { "stan": [ "php vendor/bin/phpstan analyse" diff --git a/src/Laravel/Facades/Invokator.php b/src/Laravel/Facades/Invokator.php new file mode 100644 index 0000000..7579bd1 --- /dev/null +++ b/src/Laravel/Facades/Invokator.php @@ -0,0 +1,26 @@ + $this->pipelines->add($id, $cb, $priority)); + } + + return $this->pipelines->process($id, ...$args); + } + + public function action(string $id, mixed ...$args): mixed + { + if ($args === []) { + return new Registrar(fn (mixed $cb, int $priority, int $limit): CallableCollection => $this->actions->add($id, $cb, $priority, $limit)); + } + + return $this->actions->process($id, ...$args); + } + + public function filter(string $id, mixed ...$args): mixed + { + if ($args === []) { + return new Registrar(fn (mixed $cb, int $priority, int $limit): CallableCollection => $this->filters->add($id, $cb, $priority, $limit)); + } + + return $this->filters->process($id, ...$args); + } + + public function middleware(string $id, mixed ...$args): mixed + { + if ($args === []) { + return new Registrar(fn (mixed $cb, int $priority, int $limit): CallableCollection => $this->middlewares->add($id, $cb, $priority)); + } + + return $this->middlewares->process($id, ...$args); + } + + /** + * Subscribe listeners to an event. The event name is the event's class name (or the + * value returned by HasEventName::getEventName()), so subscribe to `Event::class`. + */ + public function event(string $eventName): Registrar + { + return new Registrar(fn (mixed $cb, int $priority, int $limit) => $this->dispatcher->subscribeTo($eventName, $cb, $priority)); + } + + /** + * Dispatch a PSR-14 event object to its subscribed listeners. + */ + public function dispatch(object $event): object + { + return $this->dispatcher->dispatch($event); + } +} diff --git a/src/Laravel/Registrar.php b/src/Laravel/Registrar.php new file mode 100644 index 0000000..7e00db8 --- /dev/null +++ b/src/Laravel/Registrar.php @@ -0,0 +1,31 @@ +add(...)`). + * + * Every `add()` routes through the closure provided by the manager, which calls the + * underlying processor's own `add()` method. This matters for actions and filters whose + * `add()` wraps the callable with `limit_arguments(...)` — a behaviour that would be lost + * if we returned and mutated a raw CallableCollection instead. + */ +final class Registrar +{ + /** + * @param \Closure(mixed, int, int): mixed $adder + */ + public function __construct(private \Closure $adder) + { + } + + public function add(mixed $callable, int $priority = 0, int $argumentsLimit = 1): self + { + ($this->adder)($callable, $priority, $argumentsLimit); + + return $this; + } +} diff --git a/src/Laravel/SiriusInvokatorServiceProvider.php b/src/Laravel/SiriusInvokatorServiceProvider.php new file mode 100644 index 0000000..cce3e67 --- /dev/null +++ b/src/Laravel/SiriusInvokatorServiceProvider.php @@ -0,0 +1,52 @@ +app->singleton(Invoker::class, fn ($app): Invoker => new Invoker($app)); + + foreach ([PipelineProcessor::class, ActionsProcessor::class, FiltersProcessor::class, MiddlewareProcessor::class] as $processor) { + $this->app->singleton($processor, fn ($app): PipelineProcessor|\Sirius\Invokator\Processors\ActionsProcessor|\Sirius\Invokator\Processors\FiltersProcessor|\Sirius\Invokator\Processors\MiddlewareProcessor => new $processor($app->make(Invoker::class))); + } + + $this->app->singleton(ListenerProvider::class); + $this->app->singleton(Dispatcher::class, fn ($app): Dispatcher => new Dispatcher( + $app->make(ListenerProvider::class), + $app->make(Invoker::class), + )); + + $this->app->singleton(InvokatorManager::class, fn ($app): InvokatorManager => new InvokatorManager( + $app->make(PipelineProcessor::class), + $app->make(ActionsProcessor::class), + $app->make(FiltersProcessor::class), + $app->make(MiddlewareProcessor::class), + $app->make(Dispatcher::class), + )); + $this->app->alias(InvokatorManager::class, 'invokator'); + + require_once __DIR__ . '/helpers.php'; + } + + public function boot(): void + { + Blade::directive('do_action', static fn (string $expression): string => ""); + } +} diff --git a/src/Laravel/helpers.php b/src/Laravel/helpers.php new file mode 100644 index 0000000..176572a --- /dev/null +++ b/src/Laravel/helpers.php @@ -0,0 +1,57 @@ +pipeline($id, ...$args); + } +} + +if (! function_exists('do_action')) { + /** + * Define an action (identifier only, returns a {@see Registrar}) or run it (with args). + */ + function do_action(string $id, mixed ...$args): mixed + { + return app(InvokatorManager::class)->action($id, ...$args); + } +} + +if (! function_exists('do_filter')) { + /** + * Define a filter (identifier only, returns a {@see Registrar}) or run it (with args), + * returning the filtered value. + */ + function do_filter(string $id, mixed ...$args): mixed + { + return app(InvokatorManager::class)->filter($id, ...$args); + } +} + +if (! function_exists('do_middleware')) { + /** + * Define a middleware stack (identifier only, returns a {@see Registrar}) or run it (with args). + */ + function do_middleware(string $id, mixed ...$args): mixed + { + return app(InvokatorManager::class)->middleware($id, ...$args); + } +} + +if (! function_exists('do_event')) { + /** + * Dispatch a PSR-14 event object to its subscribed listeners. + */ + function do_event(object $event): object + { + return app(InvokatorManager::class)->dispatch($event); + } +} diff --git a/tests/Laravel/BladeTest.php b/tests/Laravel/BladeTest.php new file mode 100644 index 0000000..f3284fd --- /dev/null +++ b/tests/Laravel/BladeTest.php @@ -0,0 +1,35 @@ +add(function ($user) use (&$log): void { + $log[] = $user; + }); + + // Blade only recognises @directive when it isn't glued to a preceding word char + // (here it follows `>`). The action returns null, so the directive emits nothing. + $html = Blade::render("@do_action('analytics', \$user)", ['user' => 'sam']); + + $this->assertSame('', $html); + $this->assertSame(['sam'], $log); + } + + public function test_do_filter_function_in_blade_echo(): void + { + Invokator::filter('shout')->add(fn ($v): string => strtoupper((string) $v) . '!'); + + $html = Blade::render("{{ do_filter('shout', \$title) }}", ['title' => 'hey']); + + $this->assertSame('HEY!', $html); + } +} diff --git a/tests/Laravel/ContainerWiringTest.php b/tests/Laravel/ContainerWiringTest.php new file mode 100644 index 0000000..b4d0e82 --- /dev/null +++ b/tests/Laravel/ContainerWiringTest.php @@ -0,0 +1,30 @@ +add(Greeter::class . '@greet'); + + $this->assertSame('Hello, Sam', Invokator::filter('greet', 'Sam')); + } + + public function test_plain_function_name_string_callable_works_under_the_laravel_container(): void + { + // Laravel's container throws for unbound ids; the Invoker must fall through to using + // 'trim' directly rather than asking the container to resolve it. + Invokator::filter('clean')->add('trim'); + + $this->assertSame('hi', Invokator::filter('clean', ' hi ')); + } +} diff --git a/tests/Laravel/EventTest.php b/tests/Laravel/EventTest.php new file mode 100644 index 0000000..7feb48d --- /dev/null +++ b/tests/Laravel/EventTest.php @@ -0,0 +1,57 @@ +add(function (SampleEvent $event) use (&$seen): void { + $seen[] = $event->payload; + }); + + $returned = Invokator::dispatch(new SampleEvent('hello')); + + $this->assertInstanceOf(SampleEvent::class, $returned); + $this->assertSame('hello', $returned->payload); + $this->assertSame(['hello'], $seen); + } + + public function test_do_event_helper_dispatches(): void + { + $seen = []; + Invokator::event(SampleEvent::class)->add(function (SampleEvent $event) use (&$seen): void { + $seen[] = $event->payload; + }); + + do_event(new SampleEvent('from-helper')); + + $this->assertSame(['from-helper'], $seen); + } + + public function test_stoppable_event_halts_propagation(): void + { + $order = []; + Invokator::event(StoppableSampleEvent::class)->add(function () use (&$order): void { + $order[] = 1; + }); + Invokator::event(StoppableSampleEvent::class)->add(function (StoppableSampleEvent $event) use (&$order): void { + $order[] = 2; + $event->stopPropagation(); + }); + Invokator::event(StoppableSampleEvent::class)->add(function () use (&$order): void { + $order[] = 3; + }); + + Invokator::dispatch(new StoppableSampleEvent()); + + $this->assertSame([1, 2], $order); + } +} diff --git a/tests/Laravel/FacadeTest.php b/tests/Laravel/FacadeTest.php new file mode 100644 index 0000000..31f6d2d --- /dev/null +++ b/tests/Laravel/FacadeTest.php @@ -0,0 +1,61 @@ +add(fn ($x): string => $x . 'a') + ->add(fn ($x): string => $x . 'b'); + + $this->assertInstanceOf(Registrar::class, $registrar); + $this->assertSame('xab', Invokator::pipeline('p', 'x')); + } + + public function test_filter_transforms_the_value(): void + { + Invokator::filter('up')->add(fn ($v) => strtoupper((string) $v)); + + $this->assertSame('HELLO', Invokator::filter('up', 'hello')); + } + + public function test_action_runs_for_side_effects_and_returns_null(): void + { + $log = []; + Invokator::action('log')->add(function ($x) use (&$log): void { + $log[] = "got:$x"; + }); + + $result = Invokator::action('log', 'hi'); + + $this->assertNull($result); + $this->assertSame(['got:hi'], $log); + } + + public function test_middleware_wraps_with_next(): void + { + Invokator::middleware('m') + ->add(fn ($name, $next = null) => strtoupper((string) $next($name))) + ->add(fn ($name, $next = null): string => 'Hello ' . $next($name)) + ->add(fn ($name, $next = null) => $name); + + $this->assertSame('HELLO WORLD', Invokator::middleware('m', 'world')); + } + + public function test_priority_controls_execution_order(): void + { + Invokator::pipeline('ordered') + ->add(fn ($x): string => $x . '-low', 0) + ->add(fn ($x): string => $x . '-high', 10); + + // higher priority runs first + $this->assertSame('start-high-low', Invokator::pipeline('ordered', 'start')); + } +} diff --git a/tests/Laravel/Fixtures/Dependency.php b/tests/Laravel/Fixtures/Dependency.php new file mode 100644 index 0000000..30875d9 --- /dev/null +++ b/tests/Laravel/Fixtures/Dependency.php @@ -0,0 +1,13 @@ +dependency->prefix() . $name; + } +} diff --git a/tests/Laravel/Fixtures/SampleEvent.php b/tests/Laravel/Fixtures/SampleEvent.php new file mode 100644 index 0000000..79ea1ef --- /dev/null +++ b/tests/Laravel/Fixtures/SampleEvent.php @@ -0,0 +1,12 @@ +add(fn ($x): string => $x . 'a') + ->add(fn ($x): string => $x . 'b'); + + $this->assertInstanceOf(Registrar::class, $registrar); + $this->assertSame('xab', do_pipeline('p', 'x')); + } + + public function test_do_filter_transforms_the_value(): void + { + do_filter('up')->add(fn ($v) => strtoupper((string) $v)); + + $this->assertSame('HELLO', do_filter('up', 'hello')); + } + + public function test_do_action_runs_side_effects(): void + { + $log = []; + do_action('log')->add(function ($x) use (&$log): void { + $log[] = $x; + }); + + $this->assertNull(do_action('log', 'hi')); + $this->assertSame(['hi'], $log); + } + + public function test_do_middleware_wraps_with_next(): void + { + do_middleware('m') + ->add(fn ($name, $next = null) => strtoupper((string) $next($name))) + ->add(fn ($name, $next = null): string => 'Hello ' . $next($name)) + ->add(fn ($name, $next = null) => $name); + + $this->assertSame('HELLO WORLD', do_middleware('m', 'world')); + } +} diff --git a/tests/Laravel/TestCase.php b/tests/Laravel/TestCase.php new file mode 100644 index 0000000..3933ac1 --- /dev/null +++ b/tests/Laravel/TestCase.php @@ -0,0 +1,30 @@ + + */ + protected function getPackageProviders($app): array + { + return [SiriusInvokatorServiceProvider::class]; + } + + /** + * @param \Illuminate\Foundation\Application $app + * @return array + */ + protected function getPackageAliases($app): array + { + return ['Invokator' => Invokator::class]; + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 65923ab..1be4670 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -17,6 +17,9 @@ ./src/ + + ./Laravel/ + From 88620adb4b772c38beb9abc783bcc7bab0eda308 Mon Sep 17 00:00:00 2001 From: adrianmiu Date: Sat, 6 Jun 2026 19:02:53 +0300 Subject: [PATCH 5/7] Configure php-cs-fixer to import fully-qualified class names Move php-cs-fixer to a committed .php-cs-fixer.dist.php config that layers fully_qualified_strict_types (import_symbols), no_unused_imports and ordered_imports on top of @PSR12, and point the csfix script at it. Apply the result: FQCN references (including the FQN return types Rector emitted) are imported and shortened across src/. Co-Authored-By: Claude Opus 4.8 (1M context) --- .php-cs-fixer.dist.php | 16 ++++++++++++++++ composer.json | 2 +- src/Event/Dispatcher.php | 2 +- src/Modifiers/ResolveArguments.php | 7 ++++--- src/Modifiers/WithArguments.php | 1 - src/Processors/ActionsProcessor.php | 2 +- src/Processors/CommandBus.php | 3 --- src/Processors/FiltersProcessor.php | 2 +- src/Processors/MiddlewareProcessor.php | 8 +++++--- src/Processors/PipelineProcessor.php | 4 ++-- src/Processors/SimpleCallablesProcessor.php | 2 +- 11 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 .php-cs-fixer.dist.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..a930921 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,16 @@ +in(__DIR__ . '/src'); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR12' => true, + // Import fully-qualified class names and shorten the references to the short name. + 'fully_qualified_strict_types' => ['import_symbols' => true], + 'no_unused_imports' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + ]) + ->setFinder($finder); diff --git a/composer.json b/composer.json index c75a2be..5c38237 100644 --- a/composer.json +++ b/composer.json @@ -64,7 +64,7 @@ "php vendor/bin/phpstan analyse" ], "csfix": [ - "php vendor/bin/php-cs-fixer fix --rules=@PSR12 src" + "php vendor/bin/php-cs-fixer fix" ], "test": [ "php vendor/bin/phpunit -c tests/phpunit.xml" diff --git a/src/Event/Dispatcher.php b/src/Event/Dispatcher.php index 3a7a195..7588a1d 100644 --- a/src/Event/Dispatcher.php +++ b/src/Event/Dispatcher.php @@ -7,8 +7,8 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\ListenerProviderInterface; use Psr\EventDispatcher\StoppableEventInterface; -use Sirius\Invokator\Invoker; use Sirius\Invokator\CallableCollection; +use Sirius\Invokator\Invoker; class Dispatcher implements EventDispatcherInterface { diff --git a/src/Modifiers/ResolveArguments.php b/src/Modifiers/ResolveArguments.php index 83c56f3..1cf76e6 100644 --- a/src/Modifiers/ResolveArguments.php +++ b/src/Modifiers/ResolveArguments.php @@ -4,10 +4,11 @@ namespace Sirius\Invokator\Modifiers; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; use Sirius\Invokator\ArgumentReference; use Sirius\Invokator\Invoker; use Sirius\Invokator\InvokerAwareInterface; -use Sirius\Invokator\InvokerReference; class ResolveArguments implements InvokerAwareInterface { @@ -30,8 +31,8 @@ public function setInvoker(Invoker $invoker): void /** * @param array $params * - * @throws \Psr\Container\ContainerExceptionInterface - * @throws \Psr\Container\NotFoundExceptionInterface + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface */ public function __invoke(...$params): mixed { diff --git a/src/Modifiers/WithArguments.php b/src/Modifiers/WithArguments.php index d2e904d..3a0dc80 100644 --- a/src/Modifiers/WithArguments.php +++ b/src/Modifiers/WithArguments.php @@ -7,7 +7,6 @@ use Sirius\Invokator\ArgumentReference; use Sirius\Invokator\Invoker; use Sirius\Invokator\InvokerAwareInterface; -use Sirius\Invokator\InvokerReference; class WithArguments implements InvokerAwareInterface { diff --git a/src/Processors/ActionsProcessor.php b/src/Processors/ActionsProcessor.php index e642183..ee82467 100644 --- a/src/Processors/ActionsProcessor.php +++ b/src/Processors/ActionsProcessor.php @@ -4,10 +4,10 @@ namespace Sirius\Invokator\Processors; -use Sirius\Invokator\Invoker; use Sirius\Invokator\CallableCollection; use Sirius\Invokator\CallablesRegistryInterface; use Sirius\Invokator\InvokatorInterface; +use Sirius\Invokator\Invoker; use function Sirius\Invokator\limit_arguments; diff --git a/src/Processors/CommandBus.php b/src/Processors/CommandBus.php index 6b22209..7de4989 100644 --- a/src/Processors/CommandBus.php +++ b/src/Processors/CommandBus.php @@ -4,9 +4,6 @@ namespace Sirius\Invokator\Processors; -use Sirius\Invokator\InvalidCallableException; -use Sirius\Invokator\CallableCollection; - class CommandBus extends MiddlewareProcessor { /** diff --git a/src/Processors/FiltersProcessor.php b/src/Processors/FiltersProcessor.php index 1400ce2..d836325 100644 --- a/src/Processors/FiltersProcessor.php +++ b/src/Processors/FiltersProcessor.php @@ -4,10 +4,10 @@ namespace Sirius\Invokator\Processors; -use Sirius\Invokator\Invoker; use Sirius\Invokator\CallableCollection; use Sirius\Invokator\CallablesRegistryInterface; use Sirius\Invokator\InvokatorInterface; +use Sirius\Invokator\Invoker; use function Sirius\Invokator\limit_arguments; diff --git a/src/Processors/MiddlewareProcessor.php b/src/Processors/MiddlewareProcessor.php index 8f3f9e6..f4193ba 100644 --- a/src/Processors/MiddlewareProcessor.php +++ b/src/Processors/MiddlewareProcessor.php @@ -4,16 +4,18 @@ namespace Sirius\Invokator\Processors; -use Sirius\Invokator\InvalidCallableException; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; use Sirius\Invokator\CallableCollection; +use Sirius\Invokator\InvalidCallableException; class MiddlewareProcessor extends SimpleCallablesProcessor { /** * @param array $params * - * @throws \Psr\Container\ContainerExceptionInterface - * @throws \Psr\Container\NotFoundExceptionInterface + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface * @throws InvalidCallableException */ #[\Override] diff --git a/src/Processors/PipelineProcessor.php b/src/Processors/PipelineProcessor.php index 3fc15be..23b3081 100644 --- a/src/Processors/PipelineProcessor.php +++ b/src/Processors/PipelineProcessor.php @@ -4,10 +4,10 @@ namespace Sirius\Invokator\Processors; +use Sirius\Invokator\CallableCollection; +use Sirius\Invokator\PipelinePromise; use Sirius\Invokator\SuggestedResume; use Sirius\Invokator\SuggestedRetry; -use Sirius\Invokator\PipelinePromise; -use Sirius\Invokator\CallableCollection; class PipelineProcessor extends SimpleCallablesProcessor { diff --git a/src/Processors/SimpleCallablesProcessor.php b/src/Processors/SimpleCallablesProcessor.php index e938ed5..fd5372c 100644 --- a/src/Processors/SimpleCallablesProcessor.php +++ b/src/Processors/SimpleCallablesProcessor.php @@ -4,10 +4,10 @@ namespace Sirius\Invokator\Processors; -use Sirius\Invokator\Invoker; use Sirius\Invokator\CallableCollection; use Sirius\Invokator\CallablesRegistryInterface; use Sirius\Invokator\InvokatorInterface; +use Sirius\Invokator\Invoker; class SimpleCallablesProcessor implements CallablesRegistryInterface, InvokatorInterface { From 2b10ffcc8c01f55cdae6b10084b5416176a0b540 Mon Sep 17 00:00:00 2001 From: adrianmiu Date: Sat, 6 Jun 2026 19:23:38 +0300 Subject: [PATCH 6/7] Document the Laravel integration Add a docs page covering installation/auto-discovery, the Invokator facade (define vs run, pipelines/actions/filters/middlewares, PSR-14 events), the do_* helpers, the @do_action Blade directive, and how string callables are resolved through Laravel's container. Add an "Integrations" section to the couscous menu. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/7_laravel.md | 139 ++++++++++++++++++++++++++++++++++++++++++++++ docs/couscous.yml | 6 ++ 2 files changed, 145 insertions(+) create mode 100644 docs/7_laravel.md diff --git a/docs/7_laravel.md b/docs/7_laravel.md new file mode 100644 index 0000000..26c495f --- /dev/null +++ b/docs/7_laravel.md @@ -0,0 +1,139 @@ +--- +title: Using Sirius\Invokator with Laravel +--- + +# Laravel integration + +Sirius\Invokator ships an optional Laravel bridge that exposes the library through +idiomatic Laravel surfaces: a service provider, an `Invokator` facade, a set of `do_*` +helper functions and a Blade directive. The core library stays framework-agnostic — the +bridge only loads inside a Laravel application. + +## Installation + +```bash +composer require siriusphp/invokator +``` + +The package is auto-discovered, so there is nothing to register manually. Laravel reads the +`extra.laravel` section of `composer.json` and registers: + +- the `Sirius\Invokator\Laravel\SiriusInvokatorServiceProvider` service provider, and +- the `Invokator` facade alias. + +The service provider injects Laravel's container into the `Invoker` as its PSR-11 container +(Laravel's container implements `Psr\Container\ContainerInterface`), and registers the +processors and the event dispatcher as **singletons** so that registrations made during +boot persist for the whole request. + +## Defining vs. running + +Every pattern uses the same overloaded call: pass **only the identifier** to get a builder +you can `->add()` callables to, or pass **extra arguments** to run it. + +```php +use Sirius\Invokator\Laravel\Facades\Invokator; + +// define +Invokator::pipeline('process-order') + ->add(fn ($order) => /* ... */ $order) + ->add(OrderNormalizer::class . '@handle'); + +// run +$result = Invokator::pipeline('process-order', $order); +``` + +`add()` accepts an optional priority (higher runs first) and, for actions and filters, an +optional argument limit: `->add($callable, $priority = 0, $argumentsLimit = 1)`. + +> Running a pattern with **no** arguments is not expressible through this overload, because +> `Invokator::pipeline('id')` returns the builder. For that rare case resolve the processor +> directly, e.g. `app(\Sirius\Invokator\Processors\PipelineProcessor::class)->process('id')`. + +## The `Invokator` facade + +### Pipelines, actions, filters, middlewares + +```php +// Pipeline — the result of each callable is the only argument to the next +Invokator::pipeline('slugify') + ->add(fn ($title) => trim($title)) + ->add(fn ($title) => strtolower($title)); +Invokator::pipeline('slugify', ' Hello World '); // "hello world" + +// Filter — transform a value (extra arguments are kept for every callable) +Invokator::filter('price')->add(fn ($amount) => $amount * 1.2); +Invokator::filter('price', 100); // 120 + +// Action — run callables for their side effects; returns null +Invokator::action('analytics')->add(fn ($user) => Analytics::track($user)); +Invokator::action('analytics', $user); + +// Middleware — each callable receives the arguments plus a $next callback +Invokator::middleware('http') + ->add(fn ($request, $next) => $next($request)) + ->add(fn ($request, $next) => /* terminal */ $request); +Invokator::middleware('http', $request); +``` + +### Events (PSR-14) + +Events keep PSR-14 semantics: you dispatch an event **object** and the listener key is taken +from its class name (or `HasEventName::getEventName()`). + +```php +// subscribe +Invokator::event(OrderPlaced::class)->add(function (OrderPlaced $event) { + // ... +}); + +// dispatch +Invokator::dispatch(new OrderPlaced($order)); +``` + +Events that implement `Psr\EventDispatcher\StoppableEventInterface` (for instance via the +`Sirius\Invokator\Event\Stoppable` trait) stop propagation as usual. + +## Helper functions + +The same operations are available as global helpers, prefixed with `do_` to avoid clashing +with Laravel's built-in `event()` and `action()` helpers. They are loaded by the service +provider, so they only exist inside a Laravel application. + +```php +do_pipeline('process-order')->add(/* ... */); +do_pipeline('process-order', $order); + +do_filter('price', 100); +do_action('analytics', $user); +do_middleware('http', $request); + +do_event(new OrderPlaced($order)); +``` + +## Blade + +Use the `@do_action` directive to run an action from a template (it emits nothing), and the +`do_filter()` helper inside an echo to print a filtered value: + +```blade + + + @do_action('html-head', $page) + {{ do_filter('page-title', $title) }} + +... + +``` + +## Resolving callables + +Because the `Invoker` is wired to Laravel's container, string callables are resolved through +it. The following all work: + +- **Closures** — `fn ($x) => ...`. +- **`Service@method`** — the `Service` is resolved from the container *with dependency + injection*, then `method` is called. +- **Bound service ids / invokable services** registered in the container. +- **Plain function names** (`'trim'`) and **`Class::method`** static strings — used directly + when they are not bound in the container. diff --git a/docs/couscous.yml b/docs/couscous.yml index 8fbdc84..b8de55d 100644 --- a/docs/couscous.yml +++ b/docs/couscous.yml @@ -74,6 +74,12 @@ menu: invoker: text: The callable invoker relativeUrl: 4_the_invoker.html + integrations: + name: Integrations + items: + laravel: + text: Laravel + relativeUrl: 7_laravel.html ideas: name: Ideas items: From 0229a044d8baf10e78f407bbc6601dbc83c89391 Mon Sep 17 00:00:00 2001 From: adrianmiu Date: Sat, 6 Jun 2026 19:39:19 +0300 Subject: [PATCH 7/7] Add @return type to CallableCollection::__serialize() for PHPStan PHPStan level 9 on PHP 8.4/8.5 reports missingType.iterableValue for the __serialize(): array return type, because the SplPriorityQueue parent stub differs there and no typed return is inherited. Declare the value type explicitly so analysis passes on every supported PHP version. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CallableCollection.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/CallableCollection.php b/src/CallableCollection.php index 1642ee0..cebba45 100644 --- a/src/CallableCollection.php +++ b/src/CallableCollection.php @@ -44,6 +44,9 @@ public function compare($priority1, $priority2): int return $sign * ($priority1[1] ?? 0) < $sign * ($priority2[1] ?? 0) ? 1 : -1; } + /** + * @return array> + */ public function __serialize(): array { $data = [];