Safely Invoke Any Backbone Operation, From Anywhere

GitHub last commit CI Workflow GitHub code size in bytes GitHub Issues Total Downloads Monthly Downloads

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 OperationRequestOperationResult 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): self works with zero registration, via FromArrayDeserializer (the conventional fallback).
  • A specific class can instead get its own DeserializerInterface registered on ObjectFactoryRegistry, which takes priority over the fallback — useful when construction isn’t a plain fromArray() (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.

Operation ≠ Job

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.

On this page

#php
Last updated on 15/08/2026 by Anonymous