---
title: "Operation Policy"
description: "Operation Policy"
type: "docs"
category: "doc"
tags: []
authors: [Anonymous]
date: "2026-10-10"
last_update: "2026-10-10"
time_minutes: 2
draft: false
unlisted: false
url: "https://www.derafu.dev/docs/core/backbone-dispatcher/policy"
---

# Operation Policy

## Controlling Which Operations Can Be Dispatched: `OperationPolicyInterface`

By default, any public method of a worker is dispatchable — the historical
behavior, kept as `AllowAllOperationPolicy`, `DirectDispatcher`'s 4th
constructor argument's default value. **This default is convenient, not
recommended**: "any public method" includes infrastructure methods a
worker gets from `JobsAwareTrait`/`HandlersAwareTrait`/`OptionsAwareTrait`
(`getJobs()`, `setOptions()`, ...) and from `ServiceInterface` itself
(`getId()`, `getName()`, `getDescription()`) — none of it business logic,
all of it just as dispatchable as a real operation while this policy is
active. `TaggedOperationPolicy` is the recommended choice for
anything reached from outside PHP: it only allows what is explicitly
tagged `#[Operation]`, which is the one real signal for "this is meant
to be exposed" — reflection alone cannot tell "a public method that
happens to exist" apart from "an operation" (see `AllowAllOperationPolicy`'s
own docblock). Two other policies ship with the package, and swapping the
one wired into `DirectDispatcher` is the only change needed — nothing else
in the three-tier chain knows a policy exists:

```php
use Derafu\BackboneDispatcher\Service\Policy\TaggedOperationPolicy;
use Derafu\BackboneDispatcher\Service\Policy\AllowListOperationPolicy;

// Only methods tagged with derafu/backbone's #[Operation] attribute.
$policy = new TaggedOperationPolicy($registry, $inspector);

// Only operations matching one of these ids. fnmatch() wildcards allowed,
// e.g. "billing.invoice.builder::*" for every operation of that worker,
// or "billing.*" for an entire package.
$policy = new AllowListOperationPolicy([
    'billing.invoice.builder::build',
    'billing.invoice.builder::cancel',
]);

$directDispatcher = new DirectDispatcher(
    $registry,
    $inspector,
    $resolver,
    $policy,
);
```

Two guards run in `DirectDispatcher::dispatch()`, in this order, before an
operation is ever resolved or invoked — so every tier built on top of it
enforces both without knowing they exist:

- Does the operation exist at all, as a public method declared on the
  worker? Independent of which policy is configured — throws
  `OperationNotFoundException` otherwise.
- Does the active `OperationPolicyInterface` allow it? Throws
  `OperationNotAllowedException` otherwise.

`Explorer` accepts the same `OperationPolicyInterface` as an optional
constructor argument, so documentation never advertises an operation the
dispatcher would then reject — without one, it lists everything, matching
`AllowAllOperationPolicy`. With one, the pruning goes all the way up the
tree: `getOperations()` drops operations it rejects, and
`getWorkers()`/`getComponents()`/`getPackages()` drop any branch left with
zero visible operations underneath — a worker nobody may call does not
show up at all, not even as an empty entry.

Implementing `OperationPolicyInterface` yourself (a single `isAllowed()`
method) covers any other rule — e.g. combining policies, or checking
against the current user's permissions.



---
Last updated on 10/10/2026

