open-solid/callable-invoker
Open Solid Callable Invoker is a lightweight PHP utility for invoking any callable with automatic dependency resolution. It supports closures, class methods, and invokable objects, making it easy to wire actions and handlers in Laravel applications. Clean, flexible, and framework-friendly.
Install (pin the version — this is 0.x):
composer require open-solid/callable-invoker:^0.2.1
Bind it once, resolve it everywhere. The package only needs a PSR-11 container. In Laravel, Illuminate\Container\Container already implements Psr\Container\ContainerInterface, so wire it in a service provider:
// app/Providers/AppServiceProvider.php
use OpenSolid\CallableInvoker\CallableInvoker;
use Psr\Container\ContainerInterface;
$this->app->singleton(CallableInvoker::class, function ($app) {
return new CallableInvoker($app->make(ContainerInterface::class));
});
First use case — dispatch a handler by name from config or DB:
$handler = config('actions.send_welcome_email');
// e.g. SendWelcomeEmail::class or ['NotificationHandler', 'send'] or a closure
$result = app(CallableInvoker::class)->invoke($handler, ['userId' => 42]);
The invoker reflects the callable, matches $userId from your array (and Laravel container resolution for typed deps like Mailer, LoggerInterface, etc.), fills defaults/nulls, and calls it. No app()->call() fallback needed if you standardize on this seam.
Where to look first in the repo:
invoke() vs call()).NotFoundException/ContainerExceptionInterface re-throws — these propagate Laravel's binding exceptions when a dep can't be resolved.Validate the gap versus core before committing. app()->call($callable, $params) already does container-aware resolution. If your only need is dispatching a service-bound callable with typed deps, you don't need this package. It earns its keep when you need one reusable, framework-agnostic primitive and predictable semantics across PHP callables (closure, [$obj, 'method'], [ClassName::class, 'method'], __invoke), and when you want an easily mocked interface.
1. Contextual dispatch from config/DB — the primary use case.
Store the callable reference, not the code path:
// config/webhooks.php
return [
'user.created' => [
'SendWelcomeEmail::class', // invokable
['App\Hooks\NotifySlack', 'post'], // array callable
],
];
Then in the dispatcher:
class WebhookDispatcher
{
public function __construct(private CallableInvoker $invoker) {}
public function dispatch(string $event, array $context): void
{
foreach (config("webhooks.$event", []) as $handler) {
$callable = is_string($handler) ? app($handler) : $handler;
$this->invoker->invoke($callable, $context);
}
}
}
Note the app($handler) — the invoker resolves parameters, not the callable's class. You still need to materialize the object (or pass the class name and let the invoker handle it if it supports that form — verify in the source).
2. Action/command pattern with a thin wrapper for testability.
Don't spread CallableInvoker across your codebase. Wrap it behind a domain interface so you can swap in app()->call() or a stub later, and so upgrades from v0.x are contained to one file:
interface ActionRunner
{
public function run(callable|string $action, array $input = []): mixed;
}
final class InvokerActionRunner implements ActionRunner
{
public function __construct(private CallableInvoker $invoker) {}
public function run(callable|string $action, array $input = []): mixed
{
if (is_string($action)) {
$action = app($action);
}
return $this->invoker->invoke($action, $input);
}
}
Every other class depends on ActionRunner, which is trivial to mock:
$this->mock(ActionRunner::class)
->shouldReceive('run')
->with(SendWelcomeEmail::class, ['userId' => 42])
->once();
3. Strategy registries and plugin hooks.
For multi-tenant or feature-flagged workflows, resolve the callable at runtime and pass tenant/context data as parameters:
$strategy = $tenant->resolveOnboardingStrategy(); // returns a callable
$strategyResult = app(CallableInvoker::class)->invoke($strategy, [
'tenant' => $tenant,
'user' => $user,
'plan' => $plan,
]);
Any parameter the invoker can't match to your array is delegated to the container — so a handler that declares User $user, NotificationService $notifier will get $user from your array and $notifier from the container in one call.
4. Queue-job-friendly: pass closures only when safe.
Closures cannot be serialized onto a queue. Prefer class-name/array callables for anything that crosses a queue boundary:
// Good — serializable
ProcessOrder::dispatch($order, ['handler' => [ShipOrder::class, 'handle']]);
// In the job:
$this->invoker->invoke($this->handler, ['order' => $this->order]);
5. Integration with Laravel events / form handlers.
The invoker is a clean seam behind your own event-dispatcher glue when you need per-tenant or per-config handlers:
Event::listen(OrderPlaced::class, function (OrderPlaced $event) {
foreach (config('order.handlers') as $callable) {
app(CallableInvoker::class)->invoke($callable, ['event' => $event]);
}
});
Verify the method name before you commit. The repo docs are sparse at v0.2.x. Check the source to confirm whether the entry point is invoke(), call(), or __invoke(). The pattern is the same but the naming matters for your wrapper interface.
Match semantics of app()->call() before you migrate anything. Laravel's app()->call() supports named-argument overrides keyed by the parameter name, plus positional args. Test:
?Bar $b = null and you didn't pass it, does it return null or throw?Foo|Bar) resolved by the container or rejected?Write a few characterization tests against both your wrapper and app()->call() so behavior is pinned. This is the main risk of swapping.
Do not assume route-model binding or contextual binding. app()->call() honors contextual bindings and route parameters; this package resolves from the PSR-11 container directly. If your handler depends on a route-bound User, pass it explicitly via the parameter array. Don't rely on the container to know the current request.
Singleton in the provider is important. Register CallableInvoker as a singleton, not a bind. Reconstructing on every call wastes reflection overhead. If you ever need to clear state (you shouldn't), a scoped binding also works.
Exception handling lives at the boundary. The invoker will re-throw the underlying container exception (usually BindingResolutionException from Laravel) when a dependency can't be resolved — it does not swallow it. Treat this as an explicit failure. Catch it in the dispatcher and log the offending callable, otherwise a bad config value will look like a random 500:
try {
$this->invoker->invoke($callable, $input);
} catch (\Psr\Container\ContainerExceptionInterface $e) {
Log::error('Callable dispatch failed', [
'callable' => is_string($callable) ? $callable : get_debug_type($callable),
'error' => $e->getMessage(),
]);
throw $e;
}
Array callables with static class + method ([ClassName::class, 'method']) are supported — this is the pattern you actually want from config files, since no instance is required. But be careful: if the class has a constructor requiring deps and the method is non-static, the invoker must instantiate it via the container. Test this path explicitly — it's the one where a v0.x behavior change would hurt most.
No sandboxing — do not feed user-controlled callables. This package executes whatever you pass it. If the callable reference comes from user input (plugin marketplace, admin panel), you need an allow-list in your wrapper:
private const ALLOWED = [SendWelcomeEmail::class, ShipOrder::class];
if (is_string($action) && !in_array($action, self::ALLOWED, true)) {
throw new \InvalidArgumentException("Unknown action: {$action}");
}
Vendor/pin aggressively. At v0.2.1 (a patch with no changelog detail), API changes are realistic at 0.3. Lock in composer.json to a caret on the minor (^0.2.1) and keep a thin wrapper so an upgrade — or a swap to app()->call() — is a one-file change. Reassess at 1.0 for revenue-critical code paths.
Debugging tips.
(new ReflectionFunction(Closure::fromCallable($callable))) — the invoker's decisions follow that signature.new, check that the service provider binding for the callable is not binded behind a feature flag that isn't active in that environment.app(CallableInvoker::class) from the container rather than new CallableInvoker(app()) — you get the same instance Laravel would give your production code, avoiding a subtle mismatch.Extension point. Because the constructor takes a ContainerInterface, you can pass an adapted container (a decorator that applies cross-cutting concerns like authorization or metrics) without touching the invoker. That's the cleanest place to add "can this user run this action?" checks.
How can I help you explore Laravel packages today?