Safely Invoke Any Backbone Operation, From Anywhere
Generic invocation and introspection for Backbone services: turn any worker’s operation into something safely callable by id, with plain array parameters, from outside PHP’s own type system.
Why
A Backbone worker’s public methods are ordinary, statically-typed PHP methods — great for an in-process PHP caller, useless for anything else. Backbone Dispatcher adds a generic invocation layer on top: it turns a string operation id ("package.component.worker:operation") plus a plain associative array of parameters into a real, reflection-resolved, type-coerced method call on the right worker, and turns whatever comes back — a return value or an uncaught exception — into a plain, serializable shape.
That is what makes it possible for a caller with no concept of PHP reflection, PHP’s type system, or PHP exceptions — Python code across a phpy boundary, an HTTP client through Backbone API, or anything else — to invoke any Backbone operation and get back a predictable, serializable answer either way.
Installation
composer require derafu/backbone-dispatcher
The Three-Tier Dispatcher
Each tier wraps the one below it and adds exactly one thing — none of them re-implement the others’ job.
DirectDispatcherInterface
public function dispatch(
string $package, string $component, string $worker,
string $operation, array $params = []
): mixed;
Resolves the worker from the package registry, resolves and validates the parameters against the operation’s real method signature, and calls it. Returns exactly what the operation returned, as a real PHP value — no wrapping. Any Throwable propagates unaltered. Use this directly only when you’re a PHP-only caller happy to get real domain objects back and to handle exceptions yourself.
TypedDispatcherInterface
public function dispatch(OperationRequestInterface $request): OperationResultInterface;
Adapts DirectDispatcher to the OperationRequest → OperationResult shape. Still does not catch exceptions — a thrown Throwable propagates unaltered, and a successful call always has isSuccess() === true (there is no “failure” OperationResult coming out of this tier, only a thrown exception). It doesn’t serialize the return value either: a domain object comes back as the real object.
SafeDispatcherInterface
public function dispatch(OperationRequestInterface $request): OperationResultInterface;
Same signature as TypedDispatcher, but never throws: any Throwable becomes a failure OperationResult carrying a ProblemDetail. It’s also the only tier that serializes the success value before returning it — because it’s the tier meant to actually cross a process/language boundary, where an uncaught PHP exception is a dead end.
$directDispatcher = new DirectDispatcher(
$registry,
new Resolver(
new Inspector(),
new Caster(new ObjectFactoryRegistry(fallback: new FromArrayDeserializer())),
new Validator(),
),
new Invoker(), // php-di/invoker
);
$safeDispatcher = new SafeDispatcher(
new TypedDispatcher($directDispatcher),
new Serializer(),
environment: 'prod',
debug: false,
);
There is no bundled DI wiring for this chain — wire it however your application already wires services (a config/services.yaml, a PHP-DI container, etc.), using the composition above as the reference.
Dispatching an Operation
use Derafu\BackboneDispatcher\ValueObject\OperationRequest;
$request = OperationRequest::fromId(
'billing.invoice.builder:build',
['number' => 'F-001', 'amount' => 15000],
);
$result = $safeDispatcher->dispatch($request);
if ($result->isSuccess()) {
$invoice = $result->getValue(); // already serialized: array/scalar, never a raw PHP object.
} else {
$problem = $result->getProblem(); // RFC 7807-shaped ProblemDetail.
}
OperationRequest::fromId() parses "package.component.worker:operation" — an id with the wrong shape (missing :, wrong number of .-segments, any empty piece) throws InvalidOperationIdException.
Handling Failure: ProblemDetail
An RFC 7807-shaped, transport-agnostic problem description — type/title/detail/instance at the top level, everything else namespaced under extensions:
$problem->getDetail(); // The exception's own message.
$problem->getInstance(); // The failed request's id: "billing.invoice.builder:build".
$problem->getThrowable()->getClass(); // e.g. "RuntimeException" — only exposed when debug is true.
$problem->toArray();
[
'type' => 'about:blank',
'title' => 'RuntimeException',
'detail' => 'Something went wrong while running the operation.',
'instance' => 'billing.invoice.builder:build',
'extensions' => [
'timestamp' => '2026-01-20T10:00:00+00:00',
'environment' => 'prod',
'debug' => false,
'context' => [],
'throwable' => null, // hidden outside debug mode.
],
]
The wrapped SafeThrowable actively scrubs sensitive data before it ever gets this far: trace frame args are stripped, and absolute file paths are rewritten relative to a project directory ("project_dir:src/Foo.php" instead of /home/user/project/src/Foo.php) — neither call arguments nor local filesystem layout leak to whatever is on the other side of the boundary.
Turning Array Data Into Real Objects
A parameter typed as a class or interface doesn’t have to arrive pre-built — a plain array (or string, for things like base64-encoded certificates) is deserialized on the way in:
- Any class exposing a static
fromArray(array $data): selfworks with zero registration, viaFromArrayDeserializer(the conventional fallback). - A specific class can instead get its own
DeserializerInterfaceregistered onObjectFactoryRegistry, which takes priority over the fallback — useful when construction isn’t a plainfromArray()(loading a certificate from either raw data or a key pair, for example). - Union-typed parameters (
A|B) try each candidate class in order.
$resolver = new Resolver(
new Inspector(),
new Caster(new ObjectFactoryRegistry(
deserializers: [Caf::class => new CafDeserializer()], // explicit, takes priority.
fallback: new FromArrayDeserializer(), // used for everything else.
)),
new Validator(),
);
On the way out, Serializer mirrors this: arrays recurse, JsonSerializable objects recurse into jsonSerialize(), objects with a toArray() recurse into that — so a nested domain object graph comes back from SafeDispatcher as plain, nested arrays.
Discovery: Explorer and Inspector
Explorer walks the package registry (packages → components → workers → operations) purely through reflection — no _links/HATEOAS shaping, that’s left to transports like Backbone API. Inspector reads a worker’s PHPDoc and reflected parameters, including resolving {@inheritDoc} from a parent class or interface.
Worth knowing: the discovery id format uses only dots ("package.component.worker.operation"), while the invocation id format used by OperationRequest separates the operation with a colon ("package.component.worker:operation"). They are intentionally different shapes — don’t treat them as interchangeable strings.
An “operation” here is simply a public method of a worker, discovered via reflection. It has nothing to do with Backbone’s own formally-registered JobInterface/#[Job] concept — a worker’s operation may use zero, one, or several real jobs internally, and the dispatcher neither knows nor cares.
Exceptions
ResolverException
├── InvalidOperationIdException — malformed "package.component.worker:operation" id.
├── InvalidParameterTypeException — a scalar parameter has the wrong native type.
└── MissingParameterException — a required parameter is absent.
ObjectFactoryException
├── ClassNotFoundException — fromArray() target class doesn't exist.
├── FromArrayMethodNotFoundException — target class has no static fromArray().
└── NoDeserializerFoundException — no registered deserializer, and the fallback failed too.
Every exception exposes a semantic static factory (InvalidOperationIdException::forId(), MissingParameterException::forParameter(), etc.) instead of a public constructor, and is translatable via derafu/translation.
Requirements
PHP 8.5+. Depends on derafu/backbone and derafu/translation.